From 5697d6143a4b8c4595bf823fdce4d123372f261e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 20:07:44 -0600 Subject: [PATCH 01/15] AI: Update bundled PHP AI Client library to 1.4.0. Regenerated via `bash tools/php-ai-client/installer.sh --version=1.4.0`. The 1.4.0 release adds embedding generation support, including the new EmbeddingBuilder, embedding lifecycle events, Embedding/EmbeddingResult DTOs, and a shared ModelResolver for model selection. Full changelog: https://github.com/WordPress/php-ai-client/releases/tag/1.4.0 Co-Authored-By: Claude Fable 5 --- .../php-ai-client/src/AiClient.php | 94 ++++- .../src/Builders/EmbeddingBuilder.php | 256 ++++++++++++ .../src/Builders/PromptBuilder.php | 339 +--------------- .../Builders/Traits/ModelResolutionTrait.php | 118 ++++++ .../Events/AfterGenerateEmbeddingEvent.php | 108 +++++ .../Events/BeforeGenerateEmbeddingEvent.php | 89 +++++ .../src/Files/Enums/MediaOrientationEnum.php | 2 +- .../Util/ModalityCombinationsUtil.php | 48 +++ .../src/Providers/ModelResolver.php | 372 ++++++++++++++++++ .../src/Providers/Models/DTO/ModelConfig.php | 39 +- .../Models/DTO/ModelRequirements.php | 91 ++++- .../EmbeddingGenerationModelInterface.php | 24 ++ .../src/Providers/Models/Enums/OptionEnum.php | 2 + .../src/Results/DTO/Embedding.php | 168 ++++++++ .../src/Results/DTO/EmbeddingResult.php | 225 +++++++++++ 15 files changed, 1635 insertions(+), 340 deletions(-) create mode 100644 src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php create mode 100644 src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php create mode 100644 src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php create mode 100644 src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ModelResolver.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php create mode 100644 src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index 43be86aa0be4d..55bda55b59d9c 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -5,6 +5,7 @@ use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface; +use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; @@ -13,6 +14,8 @@ use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\DTO\Embedding; +use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -76,6 +79,7 @@ * @since 0.1.0 * * @phpstan-import-type Prompt from PromptBuilder + * @phpstan-import-type EmbeddingInput from EmbeddingBuilder * * phpcs:ignore Generic.Files.LineLength.TooLong */ @@ -84,7 +88,7 @@ class AiClient /** * @var string The version of the AI Client. */ - public const VERSION = '1.3.1'; + public const VERSION = '1.4.0'; /** * @var ProviderRegistry|null The default provider registry instance. */ @@ -211,6 +215,23 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null { return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher); } + /** + * Creates an embedding builder for fluent embedding generation. + * + * Accepts a single input or a list of inputs; each input is embedded independently. Chain an + * embedding generation method such as generateEmbedding() or generateEmbeddings() to produce + * one vector per input. + * + * @since 1.4.0 + * + * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return EmbeddingBuilder The embedding builder instance. + */ + public static function input($input = null, ?ProviderRegistry $registry = null): EmbeddingBuilder + { + return new EmbeddingBuilder($registry ?? self::defaultRegistry(), $input, self::$eventDispatcher); + } /** * Generates content using a unified API that automatically detects model capabilities. * @@ -334,6 +355,59 @@ public static function generateVideoResult($prompt, $modelOrConfig = null, ?Prov self::validateModelOrConfigParameter($modelOrConfig); return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult(); } + /** + * Generates embeddings using the traditional API approach. + * + * @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 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. + */ + public static function generateEmbeddingResult($input, $modelOrConfig = null, ?ProviderRegistry $registry = null): EmbeddingResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::applyModelOrConfig(self::input($input, $registry), $modelOrConfig)->generateEmbeddingResult(); + } + /** + * Generates an embedding using the traditional API approach. + * + * @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 ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return Embedding The generated embedding vector. + */ + public static function generateEmbedding($input, $modelOrConfig = null, ?ProviderRegistry $registry = null): Embedding + { + return self::generateEmbeddingResult($input, $modelOrConfig, $registry)->getEmbedding(); + } + /** + * Generates embeddings for a list of inputs using the traditional API approach. + * + * @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 ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return list The generated embedding vectors. + */ + public static function generateEmbeddings(array $inputs, $modelOrConfig = null, ?ProviderRegistry $registry = null): array + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::applyModelOrConfig(self::input($inputs, $registry), $modelOrConfig)->generateEmbeddings(); + } /** * Creates a new message builder for fluent API usage. * @@ -375,7 +449,23 @@ private static function validateModelOrConfigParameter($modelOrConfig): void */ private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder { - $builder = self::prompt($prompt, $registry); + return self::applyModelOrConfig(self::prompt($prompt, $registry), $modelOrConfig); + } + /** + * 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 + * + * @param T $builder The builder to configure. + * @param ModelInterface|ModelConfig|null $modelOrConfig Specific model, model configuration, + * or null for default discovery. + * @return T The configured builder. + */ + private static function applyModelOrConfig($builder, $modelOrConfig) + { if ($modelOrConfig instanceof ModelInterface) { $builder->usingModel($modelOrConfig); } elseif ($modelOrConfig instanceof ModelConfig) { diff --git a/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php new file mode 100644 index 0000000000000..d822fa48eb15e --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php @@ -0,0 +1,256 @@ + The inputs to embed. + */ + protected array $inputs = []; + /** + * @var EventDispatcherInterface|null The event dispatcher for embedding lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. + */ + public function __construct(ProviderRegistry $registry, $input = null, ?EventDispatcherInterface $eventDispatcher = null) + { + $this->modelConfig = new ModelConfig(); + $this->modelResolver = new ModelResolver($registry); + $this->eventDispatcher = $eventDispatcher; + if ($input === null) { + return; + } + if (is_array($input) && array_is_list($input)) { + /** @var list $input */ + $this->withInput(...$input); + return; + } + /** @var EmbeddingInput $input */ + $this->withInput($input); + } + /** + * 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. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $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. + } + /** + * Adds one or more inputs to embed. + * + * @since 1.4.0 + * + * @param EmbeddingInput ...$input The inputs to embed, each treated as an independent input. + * @return self + * @throws InvalidArgumentException If no inputs are provided or an input is invalid. + */ + public function withInput(...$input): self + { + if ($input === []) { + throw new InvalidArgumentException('At least one input must be provided.'); + } + foreach ($input as $singleInput) { + $this->inputs[] = $this->parseInput($singleInput); + } + return $this; + } + /** + * Sets the embedding dimensions. + * + * @since 1.4.0 + * + * @param int $dimensions The embedding dimensions. + * @return self + */ + public function usingDimensions(int $dimensions): self + { + $this->modelConfig->setDimensions($dimensions); + return $this; + } + /** + * Checks whether the current inputs and configuration are supported by an available model. + * + * @since 1.4.0 + * + * @return bool True if a suitable embedding model is available. + */ + public function isSupported(): bool + { + $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); + return $this->modelResolver->isSupported($requirements); + } + /** + * Generates an embedding result from the configured inputs. + * + * @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. + */ + public function generateEmbeddingResult(): EmbeddingResult + { + if ($this->inputs === []) { + throw new InvalidArgumentException('Cannot generate embeddings from empty input. Add content using withInput().'); + } + $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())); + } + $this->dispatchEvent(new BeforeGenerateEmbeddingEvent($this->inputs, $model, $capability)); + $result = $model->generateEmbeddingResult($this->inputs); + // Embeddings map positionally to inputs, so the counts must match. + if (count($result->getEmbeddings()) !== count($this->inputs)) { + throw new RuntimeException(sprintf('Expected %d embedding(s) from the model, but received %d.', count($this->inputs), count($result->getEmbeddings()))); + } + $this->dispatchEvent(new AfterGenerateEmbeddingEvent($this->inputs, $model, $capability, $result)); + return $result; + } + /** + * Generates a single embedding from the configured input. + * + * @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. + */ + public function generateEmbedding(): Embedding + { + if (count($this->inputs) > 1) { + throw new InvalidArgumentException('generateEmbedding() returns a single vector; use generateEmbeddings() for multiple inputs.'); + } + return $this->generateEmbeddingResult()->getEmbedding(); + } + /** + * Generates embeddings from the configured inputs. + * + * @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. + */ + public function generateEmbeddings(): array + { + return $this->generateEmbeddingResult()->getEmbeddings(); + } + /** + * Parses a single input into a message part. + * + * @since 1.4.0 + * + * @param mixed $input The input to parse. Accepts a string, MessagePart, File, or + * MessagePartArrayShape. + * @return MessagePart The parsed message part. + * @throws InvalidArgumentException If the input type is not supported or is an invalid part type. + */ + private function parseInput($input): MessagePart + { + if ($input instanceof MessagePart) { + return $this->validatePart($input); + } + if (is_string($input)) { + if (trim($input) === '') { + throw new InvalidArgumentException('Cannot create an embedding input from an empty string.'); + } + return new MessagePart($input); + } + if ($input instanceof File) { + return new MessagePart($input); + } + if (is_array($input) && MessagePart::isArrayShape($input)) { + return $this->validatePart(MessagePart::fromArray($input)); + } + throw new InvalidArgumentException('Embedding input must be a string, MessagePart, File, or MessagePartArrayShape.'); + } + /** + * Validates that a message part is a valid embedding input. + * + * @since 1.4.0 + * + * @param MessagePart $part The part to validate. + * @return MessagePart The validated part. + * @throws InvalidArgumentException If the part is not a text or file part. + */ + private function validatePart(MessagePart $part): MessagePart + { + $type = $part->getType(); + if (!$type->isText() && !$type->isFile()) { + throw new InvalidArgumentException('Embedding inputs must be text or file parts.'); + } + return $part; + } + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since 1.4.0 + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php index 87b0fedd4f696..641d1915f1963 100644 --- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Builders; use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; @@ -16,11 +17,9 @@ use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; -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\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; @@ -49,34 +48,11 @@ */ class PromptBuilder { - /** - * @var ProviderRegistry The provider registry for finding suitable models. - */ - private ProviderRegistry $registry; + use ModelResolutionTrait; /** * @var list The messages in the conversation. */ protected array $messages = []; - /** - * @var ModelInterface|null The model to use for generation. - */ - protected ?ModelInterface $model = null; - /** - * @var list Ordered list of preference keys to check when selecting a model. - */ - protected array $modelPreferenceKeys = []; - /** - * @var string|null The provider ID or class name. - */ - protected ?string $providerIdOrClassName = null; - /** - * @var ModelConfig The model configuration. - */ - protected ModelConfig $modelConfig; - /** - * @var RequestOptions|null The request options for HTTP transport. - */ - protected ?RequestOptions $requestOptions = null; /** * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. */ @@ -94,8 +70,8 @@ class PromptBuilder // phpcs:enable Generic.Files.LineLength.TooLong public function __construct(ProviderRegistry $registry, $prompt = null, ?EventDispatcherInterface $eventDispatcher = null) { - $this->registry = $registry; $this->modelConfig = new ModelConfig(); + $this->modelResolver = new ModelResolver($registry); $this->eventDispatcher = $eventDispatcher; if ($prompt === null) { return; @@ -128,12 +104,10 @@ public function __clone() $this->messages = $clonedMessages; // Clone model config (ModelConfig has __clone) $this->modelConfig = clone $this->modelConfig; - // Clone request options if set (contains only primitives) - 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. + // Clone model resolver (ModelResolver has __clone; clones request options) + $this->modelResolver = clone $this->modelResolver; + // Note: $eventDispatcher is a service object and is intentionally NOT + // cloned - it should be a shared reference. } /** * Adds text to the current message. @@ -219,109 +193,6 @@ public function withHistory(Message ...$messages): self $this->messages = array_merge($messages, $this->messages); return $this; } - /** - * Sets the model to use for 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 0.1.0 - * - * @param ModelInterface $model The model to use. - * @return self - */ - public function usingModel(ModelInterface $model): self - { - $this->model = $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); - return $this; - } - /** - * Sets preferred models to evaluate in order. - * - * @since 0.2.0 - * - * @param string|ModelInterface|array{0:string,1:string} ...$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. - * @return self - * - * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. - */ - public function usingModelPreference(...$preferredModels): self - { - if ($preferredModels === []) { - throw new InvalidArgumentException('At least one model preference must be provided.'); - } - $preferenceKeys = []; - foreach ($preferredModels as $preferredModel) { - if (is_array($preferredModel)) { - // [model identifier, provider ID] tuple - if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { - throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.'); - } - [$providerId, $modelId] = $preferredModel; - $modelId = $this->normalizePreferenceIdentifier($modelId); - $providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.'); - $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - } elseif ($preferredModel instanceof ModelInterface) { - // Model instance - $modelId = $preferredModel->metadata()->getId(); - $providerId = $preferredModel->providerMetadata()->getId(); - $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - } elseif (is_string($preferredModel)) { - // Model ID - $modelId = $this->normalizePreferenceIdentifier($preferredModel); - $preferenceKey = $this->createModelPreferenceKey($modelId); - } else { - // Invalid type - throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.'); - } - $preferenceKeys[] = $preferenceKey; - } - $this->modelPreferenceKeys = $preferenceKeys; - return $this; - } - /** - * Sets the model configuration. - * - * Merges the provided configuration with the builder's configuration, - * with builder configuration taking precedence. - * - * @since 0.1.0 - * - * @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. - * - * @since 0.1.0 - * - * @param string $providerIdOrClassName The provider ID or class name. - * @return self - */ - public function usingProvider(string $providerIdOrClassName): self - { - $this->providerIdOrClassName = $providerIdOrClassName; - return $this; - } /** * Sets the system instruction. * @@ -468,19 +339,6 @@ public function usingWebSearch(WebSearch $webSearch): self $this->modelConfig->setWebSearch($webSearch); return $this; } - /** - * Sets the request options for HTTP transport. - * - * @since 0.3.0 - * - * @param RequestOptions $requestOptions The request options. - * @return self - */ - public function usingRequestOptions(RequestOptions $requestOptions): self - { - $this->requestOptions = $requestOptions; - return $this; - } /** * Sets the top log probabilities configuration. * @@ -691,8 +549,9 @@ public function isSupported(?CapabilityEnum $capability = null): bool // If no intended capability provided, infer from output modalities if ($capability === null) { // First try to infer from a specific model if one is set - if ($this->model !== null) { - $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + $model = $this->modelResolver->getModel(); + if ($model !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($model); if ($inferredCapability !== null) { $capability = $inferredCapability; } @@ -704,18 +563,7 @@ public function isSupported(?CapabilityEnum $capability = null): bool } // Build requirements with the specified capability $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); - // If the model has been set, check if it meets the requirements - if ($this->model !== null) { - return $requirements->areMetBy($this->model->metadata()); - } - try { - // Check if any models support these requirements - $models = $this->registry->findModelsMetadataForSupport($requirements); - return !empty($models); - } catch (InvalidArgumentException $e) { - // No models support the requirements - return \false; - } + return $this->modelResolver->isSupported($requirements); } /** * Checks if the prompt is supported for text generation. @@ -815,8 +663,9 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi // If capability is not provided, infer it if ($capability === null) { // First try to infer from a specific model if one is set - if ($this->model !== null) { - $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + $setModel = $this->modelResolver->getModel(); + if ($setModel !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($setModel); if ($inferredCapability !== null) { $capability = $inferredCapability; } @@ -1148,155 +997,7 @@ protected function appendPartToMessages(MessagePart $part): void private function getConfiguredModel(CapabilityEnum $capability): ModelInterface { $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); - if ($this->model !== null) { - // Explicit model was provided via usingModel(); just update config and bind dependencies. - $model = $this->model; - $model->setConfig($this->modelConfig); - $this->registry->bindModelDependencies($model); - $this->bindModelRequestOptions($model); - return $model; - } - // Retrieve the candidate models map which satisfies the requirements. - $candidateMap = $this->getCandidateModelsMap($requirements); - if (empty($candidateMap)) { - $message = sprintf('No models found that support %s for this prompt.', $capability->value); - if ($this->providerIdOrClassName !== null) { - $message = sprintf('No models found for provider "%s" that support %s for this prompt.', $this->providerIdOrClassName, $capability->value); - } - throw new InvalidArgumentException($message); - } - // Check if any preferred models match the candidates, in priority order. - if (!empty($this->modelPreferenceKeys)) { - // Find preferences that match available candidates, preserving preference order. - $matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap); - if (!empty($matchingPreferences)) { - // Get the first matching preference key - $firstMatchKey = key($matchingPreferences); - [$providerId, $modelId] = $candidateMap[$firstMatchKey]; - $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); - $this->bindModelRequestOptions($model); - return $model; - } - } - // No preference matched; fall back to the first candidate discovered. - [$providerId, $modelId] = reset($candidateMap); - $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); - $this->bindModelRequestOptions($model); - return $model; - } - /** - * Binds configured request options to the model if present and supported. - * - * Request options are only applicable to API-based models that make HTTP requests. - * - * @since 0.3.0 - * - * @param ModelInterface $model The model to bind request options to. - * @return void - */ - private function bindModelRequestOptions(ModelInterface $model): void - { - if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { - $model->setRequestOptions($this->requestOptions); - } - } - /** - * Builds a map of candidate models that satisfy the requirements for efficient lookup. - * - * @since 0.2.0 - * - * @param ModelRequirements $requirements The requirements derived from the prompt. - * @return array Map of preference keys to [providerId, modelId] tuples. - */ - private function getCandidateModelsMap(ModelRequirements $requirements): array - { - if ($this->providerIdOrClassName === null) { - // No provider locked in, gather all models across providers that meet requirements. - $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); - $candidateMap = []; - foreach ($providerModelsMetadata as $providerModels) { - $providerId = $providerModels->getProvider()->getId(); - $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); - // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) - $candidateMap = $candidateMap + $providerMap; - } - return $candidateMap; - } - // Provider set, only consider models from that provider. - $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements); - // Ensure we pass the provider ID, not the class name - $providerId = $this->registry->getProviderId($this->providerIdOrClassName); - return $this->generateMapFromCandidates($providerId, $modelsMetadata); - } - /** - * Generates a candidate map from model metadata with both provider-specific and model-only keys. - * - * @since 0.2.0 - * - * @param string $providerId The provider ID. - * @param list $modelsMetadata The models metadata to map. - * @return array Map of preference keys to [providerId, modelId] tuples. - */ - private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array - { - $map = []; - foreach ($modelsMetadata as $modelMetadata) { - $modelId = $modelMetadata->getId(); - // Add provider-specific key - $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - $map[$providerModelKey] = [$providerId, $modelId]; - // Add model-only key - $modelKey = $this->createModelPreferenceKey($modelId); - $map[$modelKey] = [$providerId, $modelId]; - } - return $map; - } - /** - * Normalizes and validates a preference identifier string. - * - * @since 0.2.0 - * - * @param mixed $value The value to normalize. - * @param string $emptyMessage The message for empty or invalid values. - * @return string The normalized identifier. - * - * @throws InvalidArgumentException If the value is not a non-empty string. - */ - private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string - { - if (!is_string($value)) { - throw new InvalidArgumentException($emptyMessage); - } - $trimmed = trim($value); - if ($trimmed === '') { - throw new InvalidArgumentException($emptyMessage); - } - return $trimmed; - } - /** - * Creates a preference key for a provider/model combination. - * - * @since 0.2.0 - * - * @param string $providerId The provider identifier. - * @param string $modelId The model identifier. - * @return string The generated preference key. - */ - private function createProviderModelPreferenceKey(string $providerId, string $modelId): string - { - return 'providerModel::' . $providerId . '::' . $modelId; - } - /** - * Creates a preference key for a model identifier. - * - * @since 0.2.0 - * - * @param string $modelId The model identifier. - * @return string The generated preference key. - */ - private function createModelPreferenceKey(string $modelId): string - { - return 'model::' . $modelId; + return $this->modelResolver->resolve($requirements, $this->modelConfig, 'prompt'); } /** * Parses various input types into a Message with the given role. @@ -1374,14 +1075,16 @@ private function parseMessage($input, MessageRoleEnum $defaultRole): Message */ private function validateMessages(): void { - if (empty($this->messages)) { + // Work on a copy so reset() and end() do not change the property array's internal pointer. + $messages = $this->messages; + if (empty($messages)) { throw new InvalidArgumentException('Cannot generate from an empty prompt. Add content using withText() or similar methods.'); } - $firstMessage = reset($this->messages); + $firstMessage = reset($messages); if (!$firstMessage->getRole()->isUser()) { throw new InvalidArgumentException('The first message must be from a user role, not from ' . $firstMessage->getRole()->value); } - $lastMessage = end($this->messages); + $lastMessage = end($messages); if (!$lastMessage->getRole()->isUser()) { throw new InvalidArgumentException('The last message must be from a user role, not from ' . $lastMessage->getRole()->value); } diff --git a/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php new file mode 100644 index 0000000000000..bce8e00045223 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php @@ -0,0 +1,118 @@ +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); + return $this; + } + /** + * Sets preferred models to evaluate in order. + * + * @since 0.2.0 + * + * @param string|ModelInterface|array{0:string,1:string} ...$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. + * @return self + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function usingModelPreference(...$preferredModels): self + { + $this->modelResolver->setModelPreferences(...$preferredModels); + 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. + * + * @since 0.1.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return self + */ + public function usingProvider(string $providerIdOrClassName): self + { + $this->modelResolver->setProvider($providerIdOrClassName); + return $this; + } + /** + * Sets the request options for HTTP transport. + * + * @since 0.3.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->modelResolver->setRequestOptions($requestOptions); + return $this; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php b/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php new file mode 100644 index 0000000000000..d8a4644cecd48 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php @@ -0,0 +1,108 @@ + The inputs that were sent to the model. + */ + private array $inputs; + /** + * @var ModelInterface The model that generated embeddings. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum The capability that was used for generation. + */ + private CapabilityEnum $capability; + /** + * @var EmbeddingResult The result from the model. + */ + private EmbeddingResult $result; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param list $inputs The inputs that were sent to the model. + * @param ModelInterface $model The model that generated embeddings. + * @param CapabilityEnum $capability The capability that was used for generation. + * @param EmbeddingResult $result The result from the model. + */ + public function __construct(array $inputs, ModelInterface $model, CapabilityEnum $capability, EmbeddingResult $result) + { + $this->inputs = $inputs; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + /** + * Gets the inputs that were sent to the model. + * + * @since 1.4.0 + * + * @return list The inputs. + */ + public function getInputs(): array + { + return $this->inputs; + } + /** + * Gets the model that generated embeddings. + * + * @since 1.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability that was used for generation. + * + * @since 1.4.0 + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + /** + * Gets the result from the model. + * + * @since 1.4.0 + * + * @return EmbeddingResult The result. + */ + public function getResult(): EmbeddingResult + { + return $this->result; + } + /** + * Performs a deep clone of the event. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $this->inputs = $clonedInputs; + $this->result = clone $this->result; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php new file mode 100644 index 0000000000000..9a6a6b4df06ee --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php @@ -0,0 +1,89 @@ + The inputs to be sent to the model. + */ + private array $inputs; + /** + * @var ModelInterface The model that will generate embeddings. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum The capability being used for generation. + */ + private CapabilityEnum $capability; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param list $inputs The inputs to be sent to the model. + * @param ModelInterface $model The model that will generate embeddings. + * @param CapabilityEnum $capability The capability being used for generation. + */ + public function __construct(array $inputs, ModelInterface $model, CapabilityEnum $capability) + { + $this->inputs = $inputs; + $this->model = $model; + $this->capability = $capability; + } + /** + * Gets the inputs to be sent to the model. + * + * @since 1.4.0 + * + * @return list The inputs. + */ + public function getInputs(): array + { + return $this->inputs; + } + /** + * Gets the model that will generate embeddings. + * + * @since 1.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability being used for generation. + * + * @since 1.4.0 + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + /** + * Performs a deep clone of the event. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $this->inputs = $clonedInputs; + } +} diff --git a/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php b/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php index 21ea89364d24d..6cb7f05d99164 100644 --- a/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php +++ b/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php @@ -5,7 +5,7 @@ use WordPress\AiClient\Common\AbstractEnum; /** - * Represents the type of file storage. + * Represents the media orientation. * * @method static self square() Returns the square orientation * @method static self landscape() Returns the landscape orientation. diff --git a/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php b/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php new file mode 100644 index 0000000000000..a4bfec4cdb077 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php @@ -0,0 +1,48 @@ + $required Required modalities included in every combination. + * @param list $optional Optional modalities to generate subsets from. + * @return list> List of modality combinations, each containing + * all required modalities plus a unique subset of + * the optional modalities. + */ + public static function buildCombinations(array $required, array $optional): array + { + $combinations = []; + $count = count($optional); + $subsetCount = 1 << $count; + // 2^count. + for ($i = 0; $i < $subsetCount; $i++) { + $combo = $required; + for ($j = 0; $j < $count; $j++) { + if ($i & 1 << $j) { + $combo[] = $optional[$j]; + } + } + $combinations[] = $combo; + } + return $combinations; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php new file mode 100644 index 0000000000000..88862ed96972b --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php @@ -0,0 +1,372 @@ + Ordered list of preference keys to check when selecting a model. + */ + private array $modelPreferenceKeys = []; + /** + * @var string|null The provider ID or class name. + */ + private ?string $providerIdOrClassName = null; + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + private ?RequestOptions $requestOptions = null; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + */ + public function __construct(\WordPress\AiClient\Providers\ProviderRegistry $registry) + { + $this->registry = $registry; + } + /** + * Creates a deep clone of this resolver. + * + * Clones the request options (which contain only primitives). The registry and + * explicitly set model are service objects and are intentionally NOT cloned, as + * they should be shared references. + * + * @since 1.4.0 + */ + public function __clone() + { + if ($this->requestOptions !== null) { + $this->requestOptions = clone $this->requestOptions; + } + } + /** + * Sets the model to use for generation. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to use. + * @return void + */ + public function setModel(ModelInterface $model): void + { + $this->model = $model; + } + /** + * Gets the explicitly set model, if any. + * + * @since 1.4.0 + * + * @return ModelInterface|null The explicitly set model, or null if none was set. + */ + public function getModel(): ?ModelInterface + { + return $this->model; + } + /** + * Sets preferred models to evaluate in order. + * + * @since 1.4.0 + * + * @param string|ModelInterface|array{0:string,1:string} ...$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. + * @return void + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function setModelPreferences(...$preferredModels): void + { + if ($preferredModels === []) { + throw new InvalidArgumentException('At least one model preference must be provided.'); + } + $preferenceKeys = []; + foreach ($preferredModels as $preferredModel) { + if (is_array($preferredModel)) { + // [model identifier, provider ID] tuple + if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { + throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.'); + } + [$providerId, $modelId] = $preferredModel; + $modelId = $this->normalizePreferenceIdentifier($modelId); + $providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.'); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif ($preferredModel instanceof ModelInterface) { + // Model instance + $modelId = $preferredModel->metadata()->getId(); + $providerId = $preferredModel->providerMetadata()->getId(); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif (is_string($preferredModel)) { + // Model ID + $modelId = $this->normalizePreferenceIdentifier($preferredModel); + $preferenceKey = $this->createModelPreferenceKey($modelId); + } else { + // Invalid type + throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.'); + } + $preferenceKeys[] = $preferenceKey; + } + $this->modelPreferenceKeys = $preferenceKeys; + } + /** + * Sets the provider to use for generation. + * + * @since 1.4.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + */ + public function setProvider(string $providerIdOrClassName): void + { + $this->providerIdOrClassName = $providerIdOrClassName; + } + /** + * Sets the request options for HTTP transport. + * + * @since 1.4.0 + * + * @param RequestOptions $requestOptions The request options. + * @return void + */ + public function setRequestOptions(RequestOptions $requestOptions): void + { + $this->requestOptions = $requestOptions; + } + /** + * Resolves the model to use for the given requirements. + * + * If a model has been explicitly set, updates its config, binds dependencies, + * and returns it. Otherwise, finds a suitable model based on the requirements, + * honoring any configured model preferences and provider constraint. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements the model must satisfy. + * @param ModelConfig $modelConfig The model configuration to apply. + * @param string|null $subject Optional caller-specific subject for failure messages. + * @return ModelInterface The model to use. + * @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements. + */ + public function resolve(ModelRequirements $requirements, ModelConfig $modelConfig, ?string $subject = null): ModelInterface + { + if ($this->model !== null) { + // Explicit model was provided via setModel(); just update config and bind dependencies. + $model = $this->model; + $model->setConfig($modelConfig); + $this->registry->bindModelDependencies($model); + $this->bindModelRequestOptions($model); + return $model; + } + // Retrieve the candidate models map which satisfies the requirements. + $candidateMap = $this->getCandidateModelsMap($requirements); + if (empty($candidateMap)) { + // The primary capability is always the first required capability (see + // ModelRequirements::fromPromptData()/fromEmbeddingData()). + $requiredCapabilities = $requirements->getRequiredCapabilities(); + $primaryCapability = reset($requiredCapabilities); + if ($primaryCapability === \false) { + $message = 'No models found that meet the requested requirements.'; + if ($this->providerIdOrClassName !== null) { + $message = sprintf('No models found for provider "%s" that meet the requested requirements.', $this->providerIdOrClassName); + } + throw new InvalidArgumentException($message); + } + $capabilityValue = $primaryCapability->value; + $message = sprintf('No models found that support %s.', $capabilityValue); + if ($subject !== null) { + $message = sprintf('No models found that support %s for this %s.', $capabilityValue, $subject); + } + if ($this->providerIdOrClassName !== null) { + $message = sprintf('No models found for provider "%s" that support %s.', $this->providerIdOrClassName, $capabilityValue); + if ($subject !== null) { + $message = sprintf('No models found for provider "%s" that support %s for this %s.', $this->providerIdOrClassName, $capabilityValue, $subject); + } + } + throw new InvalidArgumentException($message); + } + // Check if any preferred models match the candidates, in priority order. + if (!empty($this->modelPreferenceKeys)) { + // Find preferences that match available candidates, preserving preference order. + $matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap); + if (!empty($matchingPreferences)) { + // Get the first matching preference key + $firstMatchKey = key($matchingPreferences); + [$providerId, $modelId] = $candidateMap[$firstMatchKey]; + $model = $this->registry->getProviderModel($providerId, $modelId, $modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + } + // No preference matched; fall back to the first candidate discovered. + [$providerId, $modelId] = reset($candidateMap); + $model = $this->registry->getProviderModel($providerId, $modelId, $modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + /** + * Checks whether any model can satisfy the given requirements. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements to check support for. + * @return bool True if the set model or any registered model meets the requirements. + */ + public function isSupported(ModelRequirements $requirements): bool + { + // If the model has been set, check if it meets the requirements + if ($this->model !== null) { + return $requirements->areMetBy($this->model->metadata()); + } + try { + // Check if any models support these requirements + $models = $this->registry->findModelsMetadataForSupport($requirements); + return !empty($models); + } catch (InvalidArgumentException $e) { + // No models support the requirements + return \false; + } + } + /** + * Binds configured request options to the model if present and supported. + * + * Request options are only applicable to API-based models that make HTTP requests. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to bind request options to. + * @return void + */ + private function bindModelRequestOptions(ModelInterface $model): void + { + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + } + /** + * Builds a map of candidate models that satisfy the requirements for efficient lookup. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements derived from the prompt. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function getCandidateModelsMap(ModelRequirements $requirements): array + { + if ($this->providerIdOrClassName === null) { + // No provider locked in, gather all models across providers that meet requirements. + $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + $candidateMap = []; + foreach ($providerModelsMetadata as $providerModels) { + $providerId = $providerModels->getProvider()->getId(); + $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); + // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) + $candidateMap = $candidateMap + $providerMap; + } + return $candidateMap; + } + // Provider set, only consider models from that provider. + $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements); + // Ensure we pass the provider ID, not the class name + $providerId = $this->registry->getProviderId($this->providerIdOrClassName); + return $this->generateMapFromCandidates($providerId, $modelsMetadata); + } + /** + * Generates a candidate map from model metadata with both provider-specific and model-only keys. + * + * @since 1.4.0 + * + * @param string $providerId The provider ID. + * @param list $modelsMetadata The models metadata to map. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array + { + $map = []; + foreach ($modelsMetadata as $modelMetadata) { + $modelId = $modelMetadata->getId(); + // Add provider-specific key + $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + $map[$providerModelKey] = [$providerId, $modelId]; + // Add model-only key + $modelKey = $this->createModelPreferenceKey($modelId); + $map[$modelKey] = [$providerId, $modelId]; + } + return $map; + } + /** + * Normalizes and validates a preference identifier string. + * + * @since 1.4.0 + * + * @param mixed $value The value to normalize. + * @param string $emptyMessage The message for empty or invalid values. + * @return string The normalized identifier. + * + * @throws InvalidArgumentException If the value is not a non-empty string. + */ + private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string + { + if (!is_string($value)) { + throw new InvalidArgumentException($emptyMessage); + } + $trimmed = trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException($emptyMessage); + } + return $trimmed; + } + /** + * Creates a preference key for a provider/model combination. + * + * @since 1.4.0 + * + * @param string $providerId The provider identifier. + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createProviderModelPreferenceKey(string $providerId, string $modelId): string + { + return 'providerModel::' . $providerId . '::' . $modelId; + } + /** + * Creates a preference key for a model identifier. + * + * @since 1.4.0 + * + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createModelPreferenceKey(string $modelId): string + { + return 'model::' . $modelId; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php index 00f1c69e008dc..fa465dbdf48eb 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php @@ -43,6 +43,7 @@ * outputMediaOrientation?: string, * outputMediaAspectRatio?: string, * outputSpeechVoice?: string, + * dimensions?: int, * customOptions?: array * } * @@ -70,6 +71,7 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation'; public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; + public const KEY_DIMENSIONS = 'dimensions'; public const KEY_CUSTOM_OPTIONS = 'customOptions'; /* * Note: This key is not an actual model config key, but specified here for convenience. @@ -157,6 +159,10 @@ class ModelConfig extends AbstractDataTransferObject * @var string|null Output speech voice. */ protected ?string $outputSpeechVoice = null; + /** + * @var int|null Embedding vector dimensions. + */ + protected ?int $dimensions = null; /** * @var array Custom provider-specific options. */ @@ -682,6 +688,31 @@ public function getOutputSpeechVoice(): ?string { return $this->outputSpeechVoice; } + /** + * Sets the embedding dimensions. + * + * @since 1.4.0 + * + * @param int $dimensions The embedding dimensions. + */ + public function setDimensions(int $dimensions): void + { + if ($dimensions < 1) { + throw new InvalidArgumentException('Dimensions must be greater than zero.'); + } + $this->dimensions = $dimensions; + } + /** + * Gets the embedding dimensions. + * + * @since 1.4.0 + * + * @return int|null The embedding dimensions. + */ + public function getDimensions(): ?int + { + return $this->dimensions; + } /** * Sets a single custom option. * @@ -723,7 +754,7 @@ public function getCustomOptions(): array */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false]; + return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_DIMENSIONS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Embedding vector dimensions.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false]; } /** * {@inheritDoc} @@ -799,6 +830,9 @@ public function toArray(): array if ($this->outputSpeechVoice !== null) { $data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice; } + if ($this->dimensions !== null) { + $data[self::KEY_DIMENSIONS] = $this->dimensions; + } if (!empty($this->customOptions)) { $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; } @@ -874,6 +908,9 @@ public static function fromArray(array $array): self if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) { $config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]); } + if (isset($array[self::KEY_DIMENSIONS])) { + $config->setDimensions($array[self::KEY_DIMENSIONS]); + } if (isset($array[self::KEY_CUSTOM_OPTIONS])) { $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); } diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php index 54d0f7cb37dcc..d1ab1d3e6c943 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; +use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; @@ -143,24 +144,9 @@ public static function fromPromptData(CapabilityEnum $capability, array $message $hasFunctionMessageParts = \false; foreach ($messages as $message) { foreach ($message->getParts() as $part) { - // Check for text input - if ($part->getType()->isText()) { - $inputModalities[] = ModalityEnum::text(); - } - // Check for file inputs - if ($part->getType()->isFile()) { - $file = $part->getFile(); - if ($file !== null) { - if ($file->isImage()) { - $inputModalities[] = ModalityEnum::image(); - } elseif ($file->isAudio()) { - $inputModalities[] = ModalityEnum::audio(); - } elseif ($file->isVideo()) { - $inputModalities[] = ModalityEnum::video(); - } elseif ($file->isDocument() || $file->isText()) { - $inputModalities[] = ModalityEnum::document(); - } - } + $modality = self::inputModalityForPart($part); + if ($modality !== null) { + $inputModalities[] = $modality; } // Check for function calls/responses (these might require special capabilities) if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) { @@ -183,6 +169,72 @@ public static function fromPromptData(CapabilityEnum $capability, array $message // Step 6: Return new ModelRequirements return new self($capabilities, $requiredOptions); } + /** + * Creates ModelRequirements from embedding input data and model configuration. + * + * Unlike {@see self::fromPromptData()}, embedding inputs are independent items rather than a + * conversation, so no chat history capability is inferred. Each input contributes its input + * modality (text or file) to the requirements. + * + * @since 1.4.0 + * + * @param list $inputs The embedding inputs. + * @param ModelConfig $modelConfig The model configuration. + * @return self The created requirements. + */ + public static function fromEmbeddingData(array $inputs, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self + { + $capabilities = [CapabilityEnum::embeddingGeneration()]; + $inputModalities = []; + // Analyze each input to determine required input modalities. Function call/response parts + // are not valid embedding inputs and are rejected before reaching this point. + foreach ($inputs as $part) { + $modality = self::inputModalityForPart($part); + if ($modality !== null) { + $inputModalities[] = $modality; + } + } + // Convert ModelConfig to RequiredOptions + $requiredOptions = self::toRequiredOptions($modelConfig); + // Add input modalities if we have any inputs + if (!empty($inputModalities)) { + // Remove duplicates + $inputModalities = array_unique($inputModalities, \SORT_REGULAR); + $requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities))); + } + return new self($capabilities, $requiredOptions); + } + /** + * Determines the input modality contributed by a message part, if any. + * + * @since 1.4.0 + * + * @param MessagePart $part The message part to analyze. + * @return ModalityEnum|null The input modality, or null if the part contributes none. + */ + private static function inputModalityForPart(MessagePart $part): ?ModalityEnum + { + // Check for text input + if ($part->getType()->isText()) { + return ModalityEnum::text(); + } + // Check for file inputs + if ($part->getType()->isFile()) { + $file = $part->getFile(); + if ($file !== null) { + if ($file->isImage()) { + return ModalityEnum::image(); + } elseif ($file->isAudio()) { + return ModalityEnum::audio(); + } elseif ($file->isVideo()) { + return ModalityEnum::video(); + } elseif ($file->isDocument() || $file->isText()) { + return ModalityEnum::document(); + } + } + } + return null; + } /** * Converts ModelConfig to an array of RequiredOptions. * @@ -253,6 +305,9 @@ private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\D if ($modelConfig->getOutputMediaAspectRatio() !== null) { $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio()); } + if ($modelConfig->getDimensions() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::dimensions(), $modelConfig->getDimensions()); + } // Add custom options as individual RequiredOptions foreach ($modelConfig->getCustomOptions() as $key => $value) { $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]); diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php new file mode 100644 index 0000000000000..e7155a20097cb --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php @@ -0,0 +1,24 @@ + $inputs The inputs to embed, one embedding generated per input. + * @return EmbeddingResult Result containing one embedding per input, in input order. + */ + public function generateEmbeddingResult(array $inputs): EmbeddingResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php index c0d089153cf07..6fb78a69ee08b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php @@ -19,6 +19,7 @@ * Dynamically loaded from ModelConfig KEY_* constants: * @method static self candidateCount() Creates an instance for CANDIDATE_COUNT option. * @method static self customOptions() Creates an instance for CUSTOM_OPTIONS option. + * @method static self dimensions() Creates an instance for DIMENSIONS option. * @method static self frequencyPenalty() Creates an instance for FREQUENCY_PENALTY option. * @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS option. * @method static self logprobs() Creates an instance for LOGPROBS option. @@ -40,6 +41,7 @@ * @method static self webSearch() Creates an instance for WEB_SEARCH option. * @method bool isCandidateCount() Checks if the option is CANDIDATE_COUNT. * @method bool isCustomOptions() Checks if the option is CUSTOM_OPTIONS. + * @method bool isDimensions() Checks if the option is DIMENSIONS. * @method bool isFrequencyPenalty() Checks if the option is FREQUENCY_PENALTY. * @method bool isFunctionDeclarations() Checks if the option is FUNCTION_DECLARATIONS. * @method bool isLogprobs() Checks if the option is LOGPROBS. diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php b/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php new file mode 100644 index 0000000000000..ecd02a013b2a8 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php @@ -0,0 +1,168 @@ + + * + * @implements IteratorAggregate + */ +final class Embedding implements Countable, IteratorAggregate, JsonSerializable +{ + /** + * @var EmbeddingList + */ + private array $values; + /** + * @var int The vector dimension count. + */ + private int $dimensions; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param EmbeddingList $values The embedding vector values. + * @param int $dimensions The vector dimension count. + */ + public function __construct(array $values, int $dimensions) + { + if ($dimensions < 1) { + throw new InvalidArgumentException('Embedding dimensions must be greater than zero'); + } + if (!self::isEmbeddingList($values)) { + if (!array_is_list($values)) { + throw new InvalidArgumentException('Embedding values must be a list array.'); + } + throw new InvalidArgumentException('Embedding values must be integers or floats.'); + } + if (count($values) !== $dimensions) { + throw new InvalidArgumentException('Embedding vector length must match dimensions.'); + } + $this->values = $values; + $this->dimensions = $dimensions; + } + /** + * Checks whether the value is a list of integers or floats. + * + * @since 1.4.0 + * + * @param mixed $values The value to check. + * @return bool True if the value is an embedding list. + * + * @phpstan-assert-if-true EmbeddingList $values + */ + private static function isEmbeddingList($values): bool + { + if (!is_array($values) || !array_is_list($values)) { + return \false; + } + foreach ($values as $value) { + if (!is_int($value) && !is_float($value)) { + return \false; + } + } + return \true; + } + /** + * Gets the vector values. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function getValues(): array + { + return $this->values; + } + /** + * Gets the vector dimension count. + * + * @since 1.4.0 + * + * @return int The vector dimension count. + */ + public function getDimensions(): int + { + return $this->dimensions; + } + /** + * Gets the number of vector values. + * + * @since 1.4.0 + * + * @return int The number of vector values. + */ + public function count(): int + { + return $this->dimensions; + } + /** + * Gets an iterator for the vector values. + * + * @since 1.4.0 + * + * @return Traversable The vector value iterator. + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->values); + } + /** + * Gets the JSON schema for embedding vectors. + * + * @since 1.4.0 + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return ['type' => 'array', 'items' => ['type' => 'number'], 'description' => 'Generated embedding vector values.']; + } + /** + * Converts the embedding to an array. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function toArray(): array + { + return $this->values; + } + /** + * Creates an embedding from an array. + * + * @since 1.4.0 + * + * @param EmbeddingList $array The embedding vector values. + * @param int $dimensions The vector dimension count. + * @return self The embedding instance. + */ + public static function fromArray(array $array, int $dimensions): self + { + return new self($array, $dimensions); + } + /** + * Converts the embedding to a JSON-serializable value. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php b/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php new file mode 100644 index 0000000000000..f914fd80a0a8f --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php @@ -0,0 +1,225 @@ +, + * dimensions: int, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata: ProviderMetadataArrayShape, + * modelMetadata: ModelMetadataArrayShape, + * additionalData?: array + * } + * + * @extends AbstractDataTransferObject + */ +class EmbeddingResult extends AbstractDataTransferObject implements ResultInterface +{ + public const KEY_ID = 'id'; + public const KEY_EMBEDDINGS = 'embeddings'; + public const KEY_DIMENSIONS = 'dimensions'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; + public const KEY_MODEL_METADATA = 'modelMetadata'; + public const KEY_ADDITIONAL_DATA = 'additionalData'; + /** + * @var string Unique identifier for this result. + */ + private string $id; + /** + * @var list + */ + private array $embeddings; + /** + * @var int The vector dimension count. + */ + private int $dimensions; + /** + * @var TokenUsage Token usage statistics. + */ + private \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage; + /** + * @var ProviderMetadata Provider metadata. + */ + private ProviderMetadata $providerMetadata; + /** + * @var ModelMetadata Model metadata. + */ + private ModelMetadata $modelMetadata; + /** + * @var array + */ + private array $additionalData; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param string $id Unique identifier for this result. + * @param list $embeddings The generated embedding vectors. + * @param int $dimensions The vector dimension count. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param ProviderMetadata $providerMetadata Provider metadata. + * @param ModelMetadata $modelMetadata Model metadata. + * @param array $additionalData Additional data. + */ + public function __construct(string $id, array $embeddings, int $dimensions, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = []) + { + if (empty($embeddings)) { + throw new InvalidArgumentException('At least one embedding must be provided'); + } + if ($dimensions < 1) { + throw new InvalidArgumentException('Embedding dimensions must be greater than zero'); + } + $normalizedEmbeddings = []; + foreach ($embeddings as $embedding) { + if (!$embedding instanceof \WordPress\AiClient\Results\DTO\Embedding) { + $embedding = new \WordPress\AiClient\Results\DTO\Embedding($embedding, $dimensions); + } elseif ($embedding->getDimensions() !== $dimensions) { + throw new InvalidArgumentException('Embedding vector length must match dimensions.'); + } + $normalizedEmbeddings[] = $embedding; + } + $this->id = $id; + $this->embeddings = $normalizedEmbeddings; + $this->dimensions = $dimensions; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + $this->additionalData = $additionalData; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getId(): string + { + return $this->id; + } + /** + * Gets the generated embedding vectors. + * + * @since 1.4.0 + * + * @return list The embeddings. + */ + public function getEmbeddings(): array + { + return $this->embeddings; + } + /** + * Gets the first generated embedding vector. + * + * @since 1.4.0 + * + * @return Embedding The first embedding. + */ + public function getEmbedding(): \WordPress\AiClient\Results\DTO\Embedding + { + return $this->embeddings[0]; + } + /** + * Gets the vector dimension count. + * + * @since 1.4.0 + * + * @return int The vector dimension count. + */ + public function getDimensions(): int + { + return $this->dimensions; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage + { + return $this->tokenUsage; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getProviderMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getModelMetadata(): ModelMetadata + { + return $this->modelMetadata; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } + /** + * Gets the JSON schema for embedding results. + * + * @since 1.4.0 + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_EMBEDDINGS => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Embedding::getJsonSchema(), 'description' => 'Generated embedding vectors.'], self::KEY_DIMENSIONS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Embedding vector dimensions.'], self::KEY_TOKEN_USAGE => \WordPress\AiClient\Results\DTO\TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), self::KEY_ADDITIONAL_DATA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Additional provider-specific data.']], 'required' => [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_DIMENSIONS, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]]; + } + /** + * Converts the embedding result to an array. + * + * @since 1.4.0 + * + * @return EmbeddingResultArrayShape The embedding result array. + */ + public function toArray(): array + { + $data = [self::KEY_ID => $this->id, self::KEY_EMBEDDINGS => array_map(static fn(\WordPress\AiClient\Results\DTO\Embedding $embedding): array => $embedding->toArray(), $this->embeddings), self::KEY_DIMENSIONS => $this->dimensions, self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray()]; + if (!empty($this->additionalData)) { + $data[self::KEY_ADDITIONAL_DATA] = $this->additionalData; + } + return $data; + } + /** + * Creates an embedding result from an array. + * + * @since 1.4.0 + * + * @param EmbeddingResultArrayShape $array The embedding result array. + * @return self The embedding result instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_DIMENSIONS, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]); + return new self($array[self::KEY_ID], $array[self::KEY_EMBEDDINGS], $array[self::KEY_DIMENSIONS], \WordPress\AiClient\Results\DTO\TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), $array[self::KEY_ADDITIONAL_DATA] ?? []); + } +} From 0f04a22335a8853c04fa89ee6278f4d3b5b869f4 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 20:07:53 -0600 Subject: [PATCH 02/15] AI: Introduce WP_AI_Client_Embedding_Builder for generating embeddings. Wraps the PHP AI Client SDK's new EmbeddingBuilder (added in 1.4.0), following the same pattern as WP_AI_Client_Prompt_Builder: snake_case method naming via __call() proxying, and WP_Error handling instead of exceptions, with an error state that preserves the fluent interface. Introduces the wp_ai_client_embedding() entry point function and the wp_ai_client_prevent_embedding filter, analogous to their prompt counterparts. Co-Authored-By: Claude Fable 5 --- src/wp-includes/ai-client.php | 24 ++ .../class-wp-ai-client-embedding-builder.php | 354 ++++++++++++++++++ src/wp-settings.php | 1 + 3 files changed, 379 insertions(+) create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index b38c7b721416d..74ca766e22285 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -8,6 +8,7 @@ */ use WordPress\AiClient\AiClient; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -60,3 +61,26 @@ function wp_supports_ai(): bool { function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { return new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), $prompt ); } + +/** + * Creates a new AI embedding builder using the default provider registry. + * + * This is the main entry point for generating embeddings in WordPress. It returns + * a fluent builder that can be used to configure and generate embedding vectors. + * + * Each input can be provided as a simple string for basic text inputs, or as more + * complex types. Pass multiple arguments to embed multiple inputs at once, each + * producing its own embedding vector. + * + * @since 7.1.0 + * + * @param string|MessagePart|File|array ...$input Optional. Initial input(s) to embed. + * A string for simple text inputs, + * a MessagePart or File object for + * structured content, or an array for + * a message part array shape. + * @return WP_AI_Client_Embedding_Builder The embedding builder instance. + */ +function wp_ai_client_embedding( ...$input ): WP_AI_Client_Embedding_Builder { + return new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), array() === $input ? null : $input ); +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php new file mode 100644 index 0000000000000..1a0a8fc46cdfa --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php @@ -0,0 +1,354 @@ +|WP_Error generate_embeddings() Generates embeddings from the configured inputs. + */ +class WP_AI_Client_Embedding_Builder { + + /** + * Wrapped embedding builder instance from the PHP AI Client SDK. + * + * @since 7.1.0 + * @var EmbeddingBuilder + */ + private EmbeddingBuilder $builder; + + /** + * WordPress error instance, if any error occurred during method calls. + * + * @since 7.1.0 + * @var WP_Error|null + */ + private ?WP_Error $error = null; + + /** + * List of methods that generate embeddings from the inputs. + * + * Structured as a map for faster lookups. + * + * @since 7.1.0 + * @var array + */ + private static array $generating_methods = array( + 'generate_embedding_result' => true, + 'generate_embedding' => true, + 'generate_embeddings' => true, + ); + + /** + * List of methods that check whether the embedding generation is supported. + * + * Structured as a map for faster lookups. + * + * @since 7.1.0 + * @var array + */ + private static array $support_check_methods = array( + 'is_supported' => true, + ); + + /** + * Constructor. + * + * @since 7.1.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param EmbeddingInput|list|null $input Optional. Initial input(s) to embed. + * A string for simple text inputs, + * a MessagePart or File object for + * structured content, an array for a + * message part array shape, or a list + * of any of these to embed multiple + * inputs. Default null. + */ + public function __construct( ProviderRegistry $registry, $input = null ) { + try { + $this->builder = new EmbeddingBuilder( $registry, $input, AiClient::getEventDispatcher() ); + } catch ( Exception $e ) { + $this->builder = new EmbeddingBuilder( $registry, null, AiClient::getEventDispatcher() ); + $this->error = $this->exception_to_wp_error( $e ); + } + + $default_timeout = 30.0; + + /** This filter is documented in wp-includes/ai-client/class-wp-ai-client-prompt-builder.php */ + $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); + if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { + $default_timeout = (float) $filtered_default_timeout; + } else { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: wp_ai_client_default_request_timeout */ + __( 'The %s filter must return a non-negative number.' ), + 'wp_ai_client_default_request_timeout' + ), + '7.1.0' + ); + } + + $this->builder->usingRequestOptions( + RequestOptions::fromArray( + array( + RequestOptions::KEY_TIMEOUT => $default_timeout, + ) + ) + ); + } + + /** + * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * + * This allows WordPress developers to use snake_case naming conventions. It catches + * any exceptions thrown, stores them, and returns a WP_Error when a terminate method + * is called. + * + * @since 7.1.0 + * + * @param string $name The method name in snake_case. + * @param array $arguments The method arguments. + * @return mixed The result of the method call. + */ + public function __call( string $name, array $arguments ) { + /* + * If an error occurred in a previous method call, either return the error for terminate methods, + * or return the same instance for other methods to maintain the fluent interface. + */ + if ( null !== $this->error ) { + if ( self::is_generating_method( $name ) ) { + return $this->error; + } + if ( self::is_support_check_method( $name ) ) { + return false; + } + return $this; + } + + // Check if the embedding generation should be prevented for is_supported and generate_* methods. + if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { + // If AI is not supported, then there's no need to apply the filter as the embedding will be prevented anyway. + $is_ai_disabled = ! wp_supports_ai(); + $prevent = $is_ai_disabled; + if ( ! $prevent ) { + /** + * Filters whether to prevent the embedding generation from being executed. + * + * @since 7.1.0 + * + * @param bool $prevent Whether to prevent the embedding generation. Default false. + * @param WP_AI_Client_Embedding_Builder $builder A clone of the embedding builder instance (read-only). + */ + $prevent = (bool) apply_filters( 'wp_ai_client_prevent_embedding', false, clone $this ); + } + + if ( $prevent ) { + // For the is_supported method, return false. + if ( self::is_support_check_method( $name ) ) { + return false; + } + + $error_message = $is_ai_disabled + ? __( 'AI features are not supported in this environment.' ) + : __( 'Embedding generation was prevented by a filter.' ); + + // For generate_* methods, create a WP_Error. + $this->error = new WP_Error( + 'embedding_prevented', + $error_message, + array( + 'status' => 503, + ) + ); + + if ( self::is_generating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + try { + $callable = $this->get_builder_callable( $name ); + $result = $callable( ...$arguments ); + + // If the result is an EmbeddingBuilder, return the current instance to allow method chaining. + if ( $result instanceof EmbeddingBuilder ) { + return $this; + } + + return $result; + } catch ( Exception $e ) { + $this->error = $this->exception_to_wp_error( $e ); + + if ( self::is_generating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + /** + * Converts an exception into a WP_Error with a structured error code and message. + * + * This method maps different exception types to specific WP_Error codes and HTTP status codes. + * The presence of the status codes means these WP_Error objects can be easily used in REST API responses + * or other contexts where HTTP semantics are relevant. + * + * @since 7.1.0 + * + * @param Exception $e The exception to convert. + * @return WP_Error The resulting WP_Error object. + */ + private function exception_to_wp_error( Exception $e ): WP_Error { + if ( $e instanceof NetworkException ) { + $error_code = 'embedding_network_error'; + $status_code = 503; + } elseif ( $e instanceof ClientException ) { + // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = 'embedding_client_error'; + $status_code = $e->getCode() ? $e->getCode() : 400; + } elseif ( $e instanceof ServerException ) { + // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = 'embedding_upstream_server_error'; + $status_code = $e->getCode() ? $e->getCode() : 500; + } elseif ( $e instanceof TokenLimitReachedException ) { + $error_code = 'embedding_token_limit_reached'; + $status_code = 400; + } elseif ( $e instanceof InvalidArgumentException ) { + $error_code = 'embedding_invalid_argument'; + $status_code = 400; + } else { + $error_code = 'embedding_builder_error'; + $status_code = 500; + } + + return new WP_Error( + $error_code, + $e->getMessage(), + array( + 'status' => $status_code, + 'exception_class' => get_class( $e ), + ) + ); + } + + /** + * Checks if a method name is a support check method (is_supported). + * + * @since 7.1.0 + * + * @param string $name The method name. + * @return bool True if the method is a support check method, false otherwise. + */ + private static function is_support_check_method( string $name ): bool { + return isset( self::$support_check_methods[ $name ] ); + } + + /** + * Checks if a method name is a generating method (generate_*). + * + * @since 7.1.0 + * + * @param string $name The method name. + * @return bool True if the method is a generating method, false otherwise. + */ + private static function is_generating_method( string $name ): bool { + return isset( self::$generating_methods[ $name ] ); + } + + /** + * Retrieves a callable for a given PHP AI Client SDK embedding builder method name. + * + * @since 7.1.0 + * + * @param string $name The method name in snake_case. + * @return callable The callable for the specified method. + * + * @throws BadMethodCallException If the method does not exist. + */ + protected function get_builder_callable( string $name ): callable { + $camel_case_name = $this->snake_to_camel_case( $name ); + + $method = array( $this->builder, $camel_case_name ); + if ( ! is_callable( $method ) ) { + throw new BadMethodCallException( + sprintf( + /* translators: 1: Method name. 2: Class name. */ + __( 'Method %1$s does not exist on %2$s.' ), + $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + ) + ); + } + + return $method; + } + + /** + * Converts snake_case to camelCase. + * + * @since 7.1.0 + * + * @param string $snake_case The snake_case string. + * @return string The camelCase string. + */ + private function snake_to_camel_case( string $snake_case ): string { + $parts = explode( '_', $snake_case ); + + $camel_case = $parts[0]; + $parts_count = count( $parts ); + for ( $i = 1; $i < $parts_count; $i++ ) { + $camel_case .= ucfirst( $parts[ $i ] ); + } + + return $camel_case; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index f633058c10222..82896d0ee781a 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -296,6 +296,7 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-event-dispatcher.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-embedding-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; From 1b8d8a5c14b11206a2ba3747add72dfc04852832 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 22:03:41 -0600 Subject: [PATCH 03/15] AI: Fix prompt builder tests for PHP AI Client 1.4.0 property moves. In 1.4.0, model selection state (model, registry, providerIdOrClassName, requestOptions) moved from the SDK's PromptBuilder onto its ModelResolver. Update the reflection helper to fall back to the model resolver for properties that no longer live on the wrapped builder. Co-Authored-By: Claude Fable 5 --- .../ai-client/wpAiClientPromptBuilder.php | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 0669be8cc7bb4..823854bb6fca7 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -96,6 +96,10 @@ private static function set_accessible( $reflector ) { /** * Gets the value of a protected or private property from the wrapped prompt builder. * + * Since PHP AI Client 1.4.0, model selection state (e.g. `model`, `registry`, + * `providerIdOrClassName`, `requestOptions`) lives on the builder's ModelResolver, + * so properties not found on the wrapped builder are looked up there. + * * @param WP_AI_Client_Prompt_Builder $builder The WordPress prompt builder instance. * @param string $property Property to get value for. * @return mixed The property value. @@ -107,7 +111,15 @@ private function get_wrapped_prompt_builder_property_value( WP_AI_Client_Prompt_ $wrapped_builder = $builder_property->getValue( $builder ); $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); - $the_property = $reflection_class2->getProperty( $property ); + if ( ! $reflection_class2->hasProperty( $property ) ) { + $resolver_property = $reflection_class2->getProperty( 'modelResolver' ); + self::set_accessible( $resolver_property ); + $wrapped_builder = $resolver_property->getValue( $wrapped_builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + } + + $the_property = $reflection_class2->getProperty( $property ); self::set_accessible( $the_property ); return $the_property->getValue( $wrapped_builder ); @@ -376,15 +388,7 @@ public function test_wrapped_builder_has_correct_registry() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $builder_property = $reflection_class->getProperty( 'builder' ); - self::set_accessible( $builder_property ); - $wrapped_builder = $builder_property->getValue( $prompt_builder ); - - $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) ); - $registry_property = $wrapped_builder_reflection->getProperty( 'registry' ); - self::set_accessible( $registry_property ); - $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' ); + $this->assertSame( $registry, $this->get_wrapped_prompt_builder_property_value( $prompt_builder, 'registry' ), 'Wrapped builder should have the same registry' ); } /** From efeb6ed9886010063b4cd809c46b390e6508065b Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 22:03:52 -0600 Subject: [PATCH 04/15] AI: Extract shared builder behavior into WP_AI_Client_Builder base class. WP_AI_Client_Prompt_Builder and WP_AI_Client_Embedding_Builder shared nearly all of their machinery: the snake_case __call() proxying, the WP_Error error-state handling, exception-to-WP_Error conversion, and the default request timeout setup. Move that into an abstract WP_AI_Client_Builder base class, parameterized via abstract methods for the SDK builder instantiation, error code prefix, prevent filter, and generating/support-check method maps. No behavior changes: error codes, filter names, and _doing_it_wrong notices (which continue to report the concrete class name) are identical to before. Co-Authored-By: Claude Fable 5 --- .../ai-client/class-wp-ai-client-builder.php | 380 ++++++++++++++++++ .../class-wp-ai-client-embedding-builder.php | 268 ++---------- .../class-wp-ai-client-prompt-builder.php | 299 +++----------- src/wp-settings.php | 1 + 4 files changed, 468 insertions(+), 480 deletions(-) create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-builder.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php new file mode 100644 index 0000000000000..7edd0b095f432 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -0,0 +1,380 @@ +builder = $this->create_sdk_builder( $registry, $input ); + } catch ( Exception $e ) { + $this->builder = $this->create_sdk_builder( $registry, null ); + $this->error = $this->exception_to_wp_error( $e ); + } + + $default_timeout = 30.0; + + /** + * Filters the default request timeout in seconds for AI Client HTTP requests. + * + * @since 7.0.0 + * + * @param float $default_timeout The default timeout in seconds. + */ + $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); + if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { + $default_timeout = (float) $filtered_default_timeout; + } else { + _doing_it_wrong( + get_class( $this ) . '::__construct', + sprintf( + /* translators: %s: wp_ai_client_default_request_timeout */ + __( 'The %s filter must return a non-negative number.' ), + 'wp_ai_client_default_request_timeout' + ), + '7.0.0' + ); + } + + $this->builder->usingRequestOptions( + RequestOptions::fromArray( + array( + RequestOptions::KEY_TIMEOUT => $default_timeout, + ) + ) + ); + } + + /** + * Creates the wrapped builder instance from the PHP AI Client SDK. + * + * @since 7.1.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param mixed $input Initial input content for the builder, or null. + * @return object The SDK builder instance. + */ + abstract protected function create_sdk_builder( ProviderRegistry $registry, $input ): object; + + /** + * Retrieves the prefix used for WP_Error codes created by this builder. + * + * @since 7.1.0 + * + * @return string The error code prefix, e.g. 'prompt' or 'embedding'. + */ + abstract protected function get_error_code_prefix(): string; + + /** + * Retrieves the error message to use when execution is prevented by a filter. + * + * @since 7.1.0 + * + * @return string The translated error message. + */ + abstract protected function get_prevented_error_message(): string; + + /** + * Checks whether execution is prevented by the builder's prevent filter. + * + * Child classes apply their specific filter, passing a clone of the builder + * instance for read-only inspection. + * + * @since 7.1.0 + * + * @return bool Whether execution is prevented. + */ + abstract protected function is_prevented_by_filter(): bool; + + /** + * Retrieves the methods that generate a result from the builder. + * + * Structured as a map of method name to true for faster lookups. + * + * @since 7.1.0 + * + * @return array The generating methods map. + */ + abstract protected function get_generating_methods(): array; + + /** + * Retrieves the methods that check whether the builder input is supported. + * + * Structured as a map of method name to true for faster lookups. + * + * @since 7.1.0 + * + * @return array The support check methods map. + */ + abstract protected function get_support_check_methods(): array; + + /** + * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * + * This allows WordPress developers to use snake_case naming conventions. It catches + * any exceptions thrown, stores them, and returns a WP_Error when a terminate method + * is called. + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $name The method name in snake_case. + * @param array $arguments The method arguments. + * @return mixed The result of the method call. + */ + public function __call( string $name, array $arguments ) { + /* + * If an error occurred in a previous method call, either return the error for terminate methods, + * or return the same instance for other methods to maintain the fluent interface. + */ + if ( null !== $this->error ) { + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + if ( $this->is_support_check_method( $name ) ) { + return false; + } + return $this; + } + + // Check if execution should be prevented for support check and generating methods. + if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + // If AI is not supported, then there's no need to apply the filter as execution will be prevented anyway. + $is_ai_disabled = ! wp_supports_ai(); + $prevent = $is_ai_disabled; + if ( ! $prevent ) { + $prevent = $this->is_prevented_by_filter(); + } + + if ( $prevent ) { + // For support check methods, return false. + if ( $this->is_support_check_method( $name ) ) { + return false; + } + + $error_message = $is_ai_disabled + ? __( 'AI features are not supported in this environment.' ) + : $this->get_prevented_error_message(); + + // For generating methods, create a WP_Error. + $this->error = new WP_Error( + $this->get_error_code_prefix() . '_prevented', + $error_message, + array( + 'status' => 503, + ) + ); + + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + try { + $callable = $this->get_builder_callable( $name ); + $result = $callable( ...$arguments ); + + // If the result is the wrapped SDK builder, return the current instance to allow method chaining. + if ( $result instanceof $this->builder ) { + return $this; + } + + return $result; + } catch ( Exception $e ) { + $this->error = $this->exception_to_wp_error( $e ); + + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + /** + * Converts an exception into a WP_Error with a structured error code and message. + * + * This method maps different exception types to specific WP_Error codes and HTTP status codes. + * The presence of the status codes means these WP_Error objects can be easily used in REST API responses + * or other contexts where HTTP semantics are relevant. + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param Exception $e The exception to convert. + * @return WP_Error The resulting WP_Error object. + */ + protected function exception_to_wp_error( Exception $e ): WP_Error { + $prefix = $this->get_error_code_prefix(); + + if ( $e instanceof NetworkException ) { + $error_code = $prefix . '_network_error'; + $status_code = 503; + } elseif ( $e instanceof ClientException ) { + // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = $prefix . '_client_error'; + $status_code = $e->getCode() ? $e->getCode() : 400; + } elseif ( $e instanceof ServerException ) { + // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = $prefix . '_upstream_server_error'; + $status_code = $e->getCode() ? $e->getCode() : 500; + } elseif ( $e instanceof TokenLimitReachedException ) { + $error_code = $prefix . '_token_limit_reached'; + $status_code = 400; + } elseif ( $e instanceof InvalidArgumentException ) { + $error_code = $prefix . '_invalid_argument'; + $status_code = 400; + } else { + $error_code = $prefix . '_builder_error'; + $status_code = 500; + } + + return new WP_Error( + $error_code, + $e->getMessage(), + array( + 'status' => $status_code, + 'exception_class' => get_class( $e ), + ) + ); + } + + /** + * Checks if a method name is a support check method (is_supported*). + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * and changed from static to instance method. + * + * @param string $name The method name. + * @return bool True if the method is a support check method, false otherwise. + */ + private function is_support_check_method( string $name ): bool { + $methods = $this->get_support_check_methods(); + return isset( $methods[ $name ] ); + } + + /** + * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * and changed from static to instance method. + * + * @param string $name The method name. + * @return bool True if the method is a generating method, false otherwise. + */ + private function is_generating_method( string $name ): bool { + $methods = $this->get_generating_methods(); + return isset( $methods[ $name ] ); + } + + /** + * Retrieves a callable for a given PHP AI Client SDK builder method name. + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $name The method name in snake_case. + * @return callable The callable for the specified method. + * + * @throws BadMethodCallException If the method does not exist. + */ + protected function get_builder_callable( string $name ): callable { + $camel_case_name = $this->snake_to_camel_case( $name ); + + $method = array( $this->builder, $camel_case_name ); + if ( ! is_callable( $method ) ) { + throw new BadMethodCallException( + sprintf( + /* translators: 1: Method name. 2: Class name. */ + __( 'Method %1$s does not exist on %2$s.' ), + $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + ) + ); + } + + return $method; + } + + /** + * Converts snake_case to camelCase. + * + * @since 7.0.0 + * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $snake_case The snake_case string. + * @return string The camelCase string. + */ + private function snake_to_camel_case( string $snake_case ): string { + $parts = explode( '_', $snake_case ); + + $camel_case = $parts[0]; + $parts_count = count( $parts ); + for ( $i = 1; $i < $parts_count; $i++ ) { + $camel_case .= ucfirst( $parts[ $i ] ); + } + + return $camel_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php index 1a0a8fc46cdfa..a5ab7cef9040a 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php @@ -9,12 +9,7 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\EmbeddingBuilder; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; -use WordPress\AiClient\Providers\Http\Exception\ClientException; -use WordPress\AiClient\Providers\Http\Exception\NetworkException; -use WordPress\AiClient\Providers\Http\Exception\ServerException; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; @@ -37,6 +32,8 @@ * * @since 7.1.0 * + * @see WP_AI_Client_Builder + * * @phpstan-import-type EmbeddingInput from EmbeddingBuilder * * @method self with_input(...$input) Adds one or more inputs to embed. @@ -51,23 +48,7 @@ * @method Embedding|WP_Error generate_embedding() Generates a single embedding from the configured input. * @method list|WP_Error generate_embeddings() Generates embeddings from the configured inputs. */ -class WP_AI_Client_Embedding_Builder { - - /** - * Wrapped embedding builder instance from the PHP AI Client SDK. - * - * @since 7.1.0 - * @var EmbeddingBuilder - */ - private EmbeddingBuilder $builder; - - /** - * WordPress error instance, if any error occurred during method calls. - * - * @since 7.1.0 - * @var WP_Error|null - */ - private ?WP_Error $error = null; +class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder { /** * List of methods that generate embeddings from the inputs. @@ -96,259 +77,84 @@ class WP_AI_Client_Embedding_Builder { ); /** - * Constructor. + * Creates the wrapped embedding builder instance from the PHP AI Client SDK. * * @since 7.1.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param EmbeddingInput|list|null $input Optional. Initial input(s) to embed. + * @param EmbeddingInput|list|null $input Initial input(s) to embed, or null. * A string for simple text inputs, * a MessagePart or File object for * structured content, an array for a * message part array shape, or a list * of any of these to embed multiple - * inputs. Default null. - */ - public function __construct( ProviderRegistry $registry, $input = null ) { - try { - $this->builder = new EmbeddingBuilder( $registry, $input, AiClient::getEventDispatcher() ); - } catch ( Exception $e ) { - $this->builder = new EmbeddingBuilder( $registry, null, AiClient::getEventDispatcher() ); - $this->error = $this->exception_to_wp_error( $e ); - } - - $default_timeout = 30.0; - - /** This filter is documented in wp-includes/ai-client/class-wp-ai-client-prompt-builder.php */ - $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); - if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { - $default_timeout = (float) $filtered_default_timeout; - } else { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: wp_ai_client_default_request_timeout */ - __( 'The %s filter must return a non-negative number.' ), - 'wp_ai_client_default_request_timeout' - ), - '7.1.0' - ); - } - - $this->builder->usingRequestOptions( - RequestOptions::fromArray( - array( - RequestOptions::KEY_TIMEOUT => $default_timeout, - ) - ) - ); - } - - /** - * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. - * - * This allows WordPress developers to use snake_case naming conventions. It catches - * any exceptions thrown, stores them, and returns a WP_Error when a terminate method - * is called. - * - * @since 7.1.0 - * - * @param string $name The method name in snake_case. - * @param array $arguments The method arguments. - * @return mixed The result of the method call. + * inputs. + * @return EmbeddingBuilder The SDK embedding builder instance. */ - public function __call( string $name, array $arguments ) { - /* - * If an error occurred in a previous method call, either return the error for terminate methods, - * or return the same instance for other methods to maintain the fluent interface. - */ - if ( null !== $this->error ) { - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - if ( self::is_support_check_method( $name ) ) { - return false; - } - return $this; - } - - // Check if the embedding generation should be prevented for is_supported and generate_* methods. - if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { - // If AI is not supported, then there's no need to apply the filter as the embedding will be prevented anyway. - $is_ai_disabled = ! wp_supports_ai(); - $prevent = $is_ai_disabled; - if ( ! $prevent ) { - /** - * Filters whether to prevent the embedding generation from being executed. - * - * @since 7.1.0 - * - * @param bool $prevent Whether to prevent the embedding generation. Default false. - * @param WP_AI_Client_Embedding_Builder $builder A clone of the embedding builder instance (read-only). - */ - $prevent = (bool) apply_filters( 'wp_ai_client_prevent_embedding', false, clone $this ); - } - - if ( $prevent ) { - // For the is_supported method, return false. - if ( self::is_support_check_method( $name ) ) { - return false; - } - - $error_message = $is_ai_disabled - ? __( 'AI features are not supported in this environment.' ) - : __( 'Embedding generation was prevented by a filter.' ); - - // For generate_* methods, create a WP_Error. - $this->error = new WP_Error( - 'embedding_prevented', - $error_message, - array( - 'status' => 503, - ) - ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - return $this; - } - } - - try { - $callable = $this->get_builder_callable( $name ); - $result = $callable( ...$arguments ); - - // If the result is an EmbeddingBuilder, return the current instance to allow method chaining. - if ( $result instanceof EmbeddingBuilder ) { - return $this; - } - - return $result; - } catch ( Exception $e ) { - $this->error = $this->exception_to_wp_error( $e ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - return $this; - } + protected function create_sdk_builder( ProviderRegistry $registry, $input ): object { + return new EmbeddingBuilder( $registry, $input, AiClient::getEventDispatcher() ); } /** - * Converts an exception into a WP_Error with a structured error code and message. - * - * This method maps different exception types to specific WP_Error codes and HTTP status codes. - * The presence of the status codes means these WP_Error objects can be easily used in REST API responses - * or other contexts where HTTP semantics are relevant. + * Retrieves the prefix used for WP_Error codes created by this builder. * * @since 7.1.0 * - * @param Exception $e The exception to convert. - * @return WP_Error The resulting WP_Error object. + * @return string The error code prefix. */ - private function exception_to_wp_error( Exception $e ): WP_Error { - if ( $e instanceof NetworkException ) { - $error_code = 'embedding_network_error'; - $status_code = 503; - } elseif ( $e instanceof ClientException ) { - // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'embedding_client_error'; - $status_code = $e->getCode() ? $e->getCode() : 400; - } elseif ( $e instanceof ServerException ) { - // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'embedding_upstream_server_error'; - $status_code = $e->getCode() ? $e->getCode() : 500; - } elseif ( $e instanceof TokenLimitReachedException ) { - $error_code = 'embedding_token_limit_reached'; - $status_code = 400; - } elseif ( $e instanceof InvalidArgumentException ) { - $error_code = 'embedding_invalid_argument'; - $status_code = 400; - } else { - $error_code = 'embedding_builder_error'; - $status_code = 500; - } - - return new WP_Error( - $error_code, - $e->getMessage(), - array( - 'status' => $status_code, - 'exception_class' => get_class( $e ), - ) - ); + protected function get_error_code_prefix(): string { + return 'embedding'; } /** - * Checks if a method name is a support check method (is_supported). + * Retrieves the error message to use when the embedding generation is prevented by a filter. * * @since 7.1.0 * - * @param string $name The method name. - * @return bool True if the method is a support check method, false otherwise. + * @return string The translated error message. */ - private static function is_support_check_method( string $name ): bool { - return isset( self::$support_check_methods[ $name ] ); + protected function get_prevented_error_message(): string { + return __( 'Embedding generation was prevented by a filter.' ); } /** - * Checks if a method name is a generating method (generate_*). + * Checks whether the embedding generation is prevented by the `wp_ai_client_prevent_embedding` filter. * * @since 7.1.0 * - * @param string $name The method name. - * @return bool True if the method is a generating method, false otherwise. + * @return bool Whether the embedding generation is prevented. */ - private static function is_generating_method( string $name ): bool { - return isset( self::$generating_methods[ $name ] ); + protected function is_prevented_by_filter(): bool { + /** + * Filters whether to prevent the embedding generation from being executed. + * + * @since 7.1.0 + * + * @param bool $prevent Whether to prevent the embedding generation. Default false. + * @param WP_AI_Client_Embedding_Builder $builder A clone of the embedding builder instance (read-only). + */ + return (bool) apply_filters( 'wp_ai_client_prevent_embedding', false, clone $this ); } /** - * Retrieves a callable for a given PHP AI Client SDK embedding builder method name. + * Retrieves the methods that generate embeddings from the inputs. * * @since 7.1.0 * - * @param string $name The method name in snake_case. - * @return callable The callable for the specified method. - * - * @throws BadMethodCallException If the method does not exist. + * @return array The generating methods map. */ - protected function get_builder_callable( string $name ): callable { - $camel_case_name = $this->snake_to_camel_case( $name ); - - $method = array( $this->builder, $camel_case_name ); - if ( ! is_callable( $method ) ) { - throw new BadMethodCallException( - sprintf( - /* translators: 1: Method name. 2: Class name. */ - __( 'Method %1$s does not exist on %2$s.' ), - $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - ) - ); - } - - return $method; + protected function get_generating_methods(): array { + return self::$generating_methods; } /** - * Converts snake_case to camelCase. + * Retrieves the methods that check whether the embedding generation is supported. * * @since 7.1.0 * - * @param string $snake_case The snake_case string. - * @return string The camelCase string. + * @return array The support check methods map. */ - private function snake_to_camel_case( string $snake_case ): string { - $parts = explode( '_', $snake_case ); - - $camel_case = $parts[0]; - $parts_count = count( $parts ); - for ( $i = 1; $i < $parts_count; $i++ ) { - $camel_case .= ucfirst( $parts[ $i ] ); - } - - return $camel_case; + protected function get_support_check_methods(): array { + return self::$support_check_methods; } } diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index a64957fe73157..b70a292df58f4 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -9,8 +9,6 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; @@ -18,9 +16,6 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; -use WordPress\AiClient\Providers\Http\Exception\ClientException; -use WordPress\AiClient\Providers\Http\Exception\NetworkException; -use WordPress\AiClient\Providers\Http\Exception\ServerException; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -46,6 +41,9 @@ * when a generating method is called, the WP_Error will be returned. * * @since 7.0.0 + * @since 7.1.0 Now extends the `WP_AI_Client_Builder` base class. + * + * @see WP_AI_Client_Builder * * @phpstan-import-type Prompt from PromptBuilder * @@ -104,23 +102,7 @@ * @method File|WP_Error generate_video() Generates a video from the prompt. * @method list|WP_Error generate_videos(?int $candidateCount = null) Generates multiple videos from the prompt. */ -class WP_AI_Client_Prompt_Builder { - - /** - * Wrapped prompt builder instance from the PHP AI Client SDK. - * - * @since 7.0.0 - * @var PromptBuilder - */ - private PromptBuilder $builder; - - /** - * WordPress error instance, if any error occurred during method calls. - * - * @since 7.0.0 - * @var WP_Error|null - */ - private ?WP_Error $error = null; +class WP_AI_Client_Prompt_Builder extends WP_AI_Client_Builder { /** * List of methods that generate a result from the prompt. @@ -168,61 +150,6 @@ class WP_AI_Client_Prompt_Builder { 'is_supported_for_embedding_generation' => true, ); - /** - * Constructor. - * - * @since 7.0.0 - * - * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param Prompt $prompt Optional. Initial prompt content. - * A string for simple text prompts, - * a MessagePart or Message object for - * structured content, an array for a - * message array shape, or a list of - * parts or messages for multi-turn - * conversations. Default null. - */ - public function __construct( ProviderRegistry $registry, $prompt = null ) { - try { - $this->builder = new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); - } catch ( Exception $e ) { - $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() ); - $this->error = $this->exception_to_wp_error( $e ); - } - - $default_timeout = 30.0; - - /** - * Filters the default request timeout in seconds for AI Client HTTP requests. - * - * @since 7.0.0 - * - * @param float $default_timeout The default timeout in seconds. - */ - $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); - if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { - $default_timeout = (float) $filtered_default_timeout; - } else { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: wp_ai_client_default_request_timeout */ - __( 'The %s filter must return a non-negative number.' ), - 'wp_ai_client_default_request_timeout' - ), - '7.0.0' - ); - } - - $this->builder->usingRequestOptions( - RequestOptions::fromArray( - array( - RequestOptions::KEY_TIMEOUT => $default_timeout, - ) - ) - ); - } - /** * Registers WordPress abilities as function declarations for the AI model. * @@ -278,210 +205,84 @@ public function using_abilities( ...$abilities ): self { } /** - * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * Creates the wrapped prompt builder instance from the PHP AI Client SDK. * - * This allows WordPress developers to use snake_case naming conventions. It catches - * any exceptions thrown, stores them, and returns a WP_Error when a terminate method - * is called. + * @since 7.1.0 * - * @since 7.0.0 - * - * @param string $name The method name in snake_case. - * @param array $arguments The method arguments. - * @return mixed The result of the method call. + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param Prompt $prompt Initial prompt content, or null. + * A string for simple text prompts, + * a MessagePart or Message object for + * structured content, an array for a + * message array shape, or a list of + * parts or messages for multi-turn + * conversations. + * @return PromptBuilder The SDK prompt builder instance. */ - public function __call( string $name, array $arguments ) { - /* - * If an error occurred in a previous method call, either return the error for terminate methods, - * or return the same instance for other methods to maintain the fluent interface. - */ - if ( null !== $this->error ) { - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - if ( self::is_support_check_method( $name ) ) { - return false; - } - return $this; - } - - // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. - if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { - // If AI is not supported, then there's no need to apply the filter as the prompt will be prevented anyway. - $is_ai_disabled = ! wp_supports_ai(); - $prevent = $is_ai_disabled; - if ( ! $prevent ) { - /** - * Filters whether to prevent the prompt from being executed. - * - * @since 7.0.0 - * - * @param bool $prevent Whether to prevent the prompt. Default false. - * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). - */ - $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); - } - - if ( $prevent ) { - // For is_supported* methods, return false. - if ( self::is_support_check_method( $name ) ) { - return false; - } - - $error_message = $is_ai_disabled - ? __( 'AI features are not supported in this environment.' ) - : __( 'Prompt execution was prevented by a filter.' ); - - // For generate_* and convert_text_to_speech* methods, create a WP_Error. - $this->error = new WP_Error( - 'prompt_prevented', - $error_message, - array( - 'status' => 503, - ) - ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - return $this; - } - } - - try { - $callable = $this->get_builder_callable( $name ); - $result = $callable( ...$arguments ); - - // If the result is a PromptBuilder, return the current instance to allow method chaining. - if ( $result instanceof PromptBuilder ) { - return $this; - } - - return $result; - } catch ( Exception $e ) { - $this->error = $this->exception_to_wp_error( $e ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - return $this; - } + protected function create_sdk_builder( ProviderRegistry $registry, $prompt ): object { + return new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); } /** - * Converts an exception into a WP_Error with a structured error code and message. + * Retrieves the prefix used for WP_Error codes created by this builder. * - * This method maps different exception types to specific WP_Error codes and HTTP status codes. - * The presence of the status codes means these WP_Error objects can be easily used in REST API responses - * or other contexts where HTTP semantics are relevant. + * @since 7.1.0 * - * @since 7.0.0 - * - * @param Exception $e The exception to convert. - * @return WP_Error The resulting WP_Error object. + * @return string The error code prefix. */ - private function exception_to_wp_error( Exception $e ): WP_Error { - if ( $e instanceof NetworkException ) { - $error_code = 'prompt_network_error'; - $status_code = 503; - } elseif ( $e instanceof ClientException ) { - // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'prompt_client_error'; - $status_code = $e->getCode() ? $e->getCode() : 400; - } elseif ( $e instanceof ServerException ) { - // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'prompt_upstream_server_error'; - $status_code = $e->getCode() ? $e->getCode() : 500; - } elseif ( $e instanceof TokenLimitReachedException ) { - $error_code = 'prompt_token_limit_reached'; - $status_code = 400; - } elseif ( $e instanceof InvalidArgumentException ) { - $error_code = 'prompt_invalid_argument'; - $status_code = 400; - } else { - $error_code = 'prompt_builder_error'; - $status_code = 500; - } - - return new WP_Error( - $error_code, - $e->getMessage(), - array( - 'status' => $status_code, - 'exception_class' => get_class( $e ), - ) - ); + protected function get_error_code_prefix(): string { + return 'prompt'; } /** - * Checks if a method name is a support check method (is_supported*). + * Retrieves the error message to use when the prompt is prevented by a filter. * - * @since 7.0.0 + * @since 7.1.0 * - * @param string $name The method name. - * @return bool True if the method is a support check method, false otherwise. + * @return string The translated error message. */ - private static function is_support_check_method( string $name ): bool { - return isset( self::$support_check_methods[ $name ] ); + protected function get_prevented_error_message(): string { + return __( 'Prompt execution was prevented by a filter.' ); } /** - * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * Checks whether the prompt is prevented by the `wp_ai_client_prevent_prompt` filter. * - * @since 7.0.0 + * @since 7.1.0 * - * @param string $name The method name. - * @return bool True if the method is a generating method, false otherwise. + * @return bool Whether the prompt is prevented. */ - private static function is_generating_method( string $name ): bool { - return isset( self::$generating_methods[ $name ] ); + protected function is_prevented_by_filter(): bool { + /** + * Filters whether to prevent the prompt from being executed. + * + * @since 7.0.0 + * + * @param bool $prevent Whether to prevent the prompt. Default false. + * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). + */ + return (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); } /** - * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. + * Retrieves the methods that generate a result from the prompt. * - * @since 7.0.0 - * - * @param string $name The method name in snake_case. - * @return callable The callable for the specified method. + * @since 7.1.0 * - * @throws BadMethodCallException If the method does not exist. + * @return array The generating methods map. */ - protected function get_builder_callable( string $name ): callable { - $camel_case_name = $this->snake_to_camel_case( $name ); - - $method = array( $this->builder, $camel_case_name ); - if ( ! is_callable( $method ) ) { - throw new BadMethodCallException( - sprintf( - /* translators: 1: Method name. 2: Class name. */ - __( 'Method %1$s does not exist on %2$s.' ), - $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - ) - ); - } - - return $method; + protected function get_generating_methods(): array { + return self::$generating_methods; } /** - * Converts snake_case to camelCase. + * Retrieves the methods that check whether the prompt is supported. * - * @since 7.0.0 + * @since 7.1.0 * - * @param string $snake_case The snake_case string. - * @return string The camelCase string. + * @return array The support check methods map. */ - private function snake_to_camel_case( string $snake_case ): string { - $parts = explode( '_', $snake_case ); - - $camel_case = $parts[0]; - $parts_count = count( $parts ); - for ( $i = 1; $i < $parts_count; $i++ ) { - $camel_case .= ucfirst( $parts[ $i ] ); - } - - return $camel_case; + protected function get_support_check_methods(): array { + return self::$support_check_methods; } } diff --git a/src/wp-settings.php b/src/wp-settings.php index 82896d0ee781a..298b28a2adc5b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -295,6 +295,7 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-discovery-strategy.php'; require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-event-dispatcher.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-builder.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-embedding-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; From df674f8ef65deefa398132e9b0b8e5541a237a60 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 23:19:43 -0600 Subject: [PATCH 05/15] AI: Pass the builder type to the wp_ai_client_default_request_timeout filter. Allows developers to adjust the default request timeout per builder type, e.g. a different timeout for embeddings than for prompts. The builder's class name is passed as the second filter parameter. Co-Authored-By: Claude Fable 5 --- .../ai-client/class-wp-ai-client-builder.php | 7 ++++-- .../ai-client/wpAiClientPromptBuilder.php | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php index 7edd0b095f432..28cfb9dac4ab8 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -76,10 +76,13 @@ public function __construct( ProviderRegistry $registry, $input = null ) { * Filters the default request timeout in seconds for AI Client HTTP requests. * * @since 7.0.0 + * @since 7.1.0 Added the `$builder_type` parameter. * - * @param float $default_timeout The default timeout in seconds. + * @param float $default_timeout The default timeout in seconds. + * @param string $builder_type The class name of the builder the timeout applies to, + * e.g. 'WP_AI_Client_Prompt_Builder' or 'WP_AI_Client_Embedding_Builder'. */ - $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); + $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout, get_class( $this ) ); if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { $default_timeout = (float) $filtered_default_timeout; } else { diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 823854bb6fca7..0df8e9862c764 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -203,6 +203,29 @@ public function test_constructor_sets_default_request_timeout() { $this->assertSame( 30.0, $request_options->getTimeout() ); } + /** + * Test that the request timeout filter receives the builder type. + * + * @ticket 64591 + */ + public function test_request_timeout_filter_receives_builder_type() { + $received_builder_type = null; + + add_filter( + 'wp_ai_client_default_request_timeout', + static function ( $default_timeout, $builder_type ) use ( &$received_builder_type ) { + $received_builder_type = $builder_type; + return $default_timeout; + }, + 10, + 2 + ); + + new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + $this->assertSame( WP_AI_Client_Prompt_Builder::class, $received_builder_type ); + } + /** * Test that the constructor allows overriding the default request timeout with a valid value. * From f4233d144773873e1472e1fb1933686cffe91f13 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Jul 2026 23:44:49 -0600 Subject: [PATCH 06/15] AI: Add tests for WP_AI_Client_Embedding_Builder and wp_ai_client_embedding(). Covers the WordPress wrapper behavior (not the SDK itself): constructor input parsing and the default request timeout handling, snake_case fluent proxying and error-state semantics, the wp_supports_ai() and wp_ai_client_prevent_embedding gates, exception-to-WP_Error mapping with embedding-prefixed error codes, generation through a mock embedding model, and the variadic input handling of wp_ai_client_embedding(). Co-Authored-By: Claude Fable 5 --- .../tests/ai-client/wpAiClientEmbedding.php | 97 +++ .../ai-client/wpAiClientEmbeddingBuilder.php | 694 ++++++++++++++++++ 2 files changed, 791 insertions(+) create mode 100644 tests/phpunit/tests/ai-client/wpAiClientEmbedding.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php new file mode 100644 index 0000000000000..b1182fdbe91b7 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php @@ -0,0 +1,97 @@ +getProperty( 'builder' ); + if ( PHP_VERSION_ID < 80100 ) { + $builder_property->setAccessible( true ); + } + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $inputs_property = $reflection_class2->getProperty( 'inputs' ); + if ( PHP_VERSION_ID < 80100 ) { + $inputs_property->setAccessible( true ); + } + + return $inputs_property->getValue( $wrapped_builder ); + } + + /** + * Test that wp_ai_client_embedding() returns a WP_AI_Client_Embedding_Builder instance. + * + * @ticket 64591 + */ + public function test_returns_embedding_builder_instance() { + $builder = wp_ai_client_embedding(); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that successive calls return independent builder instances. + * + * @ticket 64591 + */ + public function test_returns_independent_instances() { + $builder1 = wp_ai_client_embedding( 'First' ); + $builder2 = wp_ai_client_embedding( 'Second' ); + + $this->assertNotSame( $builder1, $builder2 ); + } + + /** + * Test that calling with no arguments creates a builder without inputs or an error state. + * + * @ticket 64591 + */ + public function test_no_arguments_creates_builder_without_inputs() { + $builder = wp_ai_client_embedding(); + + $this->assertSame( array(), $this->get_wrapped_builder_inputs( $builder ) ); + } + + /** + * Test that a single argument becomes a single input. + * + * @ticket 64591 + */ + public function test_single_argument_becomes_single_input() { + $builder = wp_ai_client_embedding( 'Test input' ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 1, $inputs ); + $this->assertInstanceOf( MessagePart::class, $inputs[0] ); + } + + /** + * Test that multiple variadic arguments each become an independent input. + * + * @ticket 64591 + */ + public function test_variadic_arguments_become_independent_inputs() { + $builder = wp_ai_client_embedding( 'First', 'Second', 'Third' ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php new file mode 100644 index 0000000000000..2a8b0c15e928c --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -0,0 +1,694 @@ +registry = $this->createMock( ProviderRegistry::class ); + } + + /** + * Makes a reflector accessible on PHP versions that require it. + * + * Since PHP 8.1 all reflection-accessed members are accessible by default, + * and PHP 8.5 deprecates the setAccessible() call. + * + * @param ReflectionProperty|ReflectionMethod $reflector The reflector to make accessible. + */ + private static function set_accessible( $reflector ) { + if ( PHP_VERSION_ID < 80100 ) { + $reflector->setAccessible( true ); + } + } + + /** + * Gets the value of a protected or private property from the wrapped embedding builder. + * + * Model selection state (e.g. `model`, `requestOptions`) lives on the builder's + * ModelResolver, so properties not found on the wrapped builder are looked up there. + * + * @param WP_AI_Client_Embedding_Builder $builder The WordPress embedding builder instance. + * @param string $property Property to get value for. + * @return mixed The property value. + */ + private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Builder $builder, string $property ) { + $reflection_class = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + self::set_accessible( $builder_property ); + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + if ( ! $reflection_class2->hasProperty( $property ) ) { + $resolver_property = $reflection_class2->getProperty( 'modelResolver' ); + self::set_accessible( $resolver_property ); + $wrapped_builder = $resolver_property->getValue( $wrapped_builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + } + + $the_property = $reflection_class2->getProperty( $property ); + self::set_accessible( $the_property ); + + return $the_property->getValue( $wrapped_builder ); + } + + /** + * Invokes the protected exception_to_wp_error() method on a builder. + * + * @param WP_AI_Client_Embedding_Builder $builder The builder to invoke the method on. + * @param Exception $exception The exception to convert. + * @return WP_Error The resulting WP_Error. + */ + private function invoke_exception_to_wp_error( WP_AI_Client_Embedding_Builder $builder, Exception $exception ): WP_Error { + $reflection = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); + $method = $reflection->getMethod( 'exception_to_wp_error' ); + self::set_accessible( $method ); + + return $method->invoke( $builder, $exception ); + } + + /** + * Creates a test model metadata instance for embedding generation. + * + * @return ModelMetadata + */ + private function create_test_embedding_model_metadata(): ModelMetadata { + return new ModelMetadata( + 'test-embedding-model', + 'Test Embedding Model', + array( CapabilityEnum::embeddingGeneration() ), + array( new SupportedOption( OptionEnum::inputModalities() ) ) + ); + } + + /** + * Creates a test EmbeddingResult with the given number of embeddings. + * + * @param int $count The number of embeddings in the result. + * @param int $dimensions Optional. The dimensions of each embedding. Default 3. + * @return EmbeddingResult + */ + private function create_test_embedding_result( int $count, int $dimensions = 3 ): EmbeddingResult { + $embeddings = array(); + for ( $i = 0; $i < $count; $i++ ) { + $embeddings[] = new Embedding( array_fill( 0, $dimensions, 0.1 * ( $i + 1 ) ), $dimensions ); + } + + return new EmbeddingResult( + 'test-embedding-result', + $embeddings, + $dimensions, + new TokenUsage( 10, 0, 10 ), + new ProviderMetadata( 'mock', 'Mock Provider', ProviderTypeEnum::cloud() ), + $this->create_test_embedding_model_metadata() + ); + } + + /** + * Creates a mock embedding generation model using an anonymous class. + * + * @param EmbeddingResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&EmbeddingGenerationModelInterface The mock model. + */ + private function create_mock_embedding_model( + EmbeddingResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_embedding_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, EmbeddingGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private EmbeddingResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + EmbeddingResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateEmbeddingResult( array $inputs ): EmbeddingResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Test that WP_AI_Client_Embedding_Builder can be instantiated. + * + * @ticket 64591 + */ + public function test_instantiation() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that WP_AI_Client_Embedding_Builder can be instantiated with initial input. + * + * @ticket 64591 + */ + public function test_instantiation_with_input() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that the constructor parses a single string input into one message part. + * + * @ticket 64591 + */ + public function test_constructor_with_string_input() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 1, $inputs ); + $this->assertInstanceOf( MessagePart::class, $inputs[0] ); + } + + /** + * Test that the constructor parses a list of inputs into one message part each. + * + * @ticket 64591 + */ + public function test_constructor_with_input_list() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 2, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } + + /** + * Test that the constructor sets the default request timeout. + * + * @ticket 64591 + */ + public function test_constructor_sets_default_request_timeout() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertSame( 30.0, $request_options->getTimeout() ); + } + + /** + * Test that the request timeout filter receives the embedding builder type. + * + * @ticket 64591 + */ + public function test_request_timeout_filter_receives_builder_type() { + $received_builder_type = null; + + add_filter( + 'wp_ai_client_default_request_timeout', + static function ( $default_timeout, $builder_type ) use ( &$received_builder_type ) { + $received_builder_type = $builder_type; + return $default_timeout; + }, + 10, + 2 + ); + + new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + $this->assertSame( WP_AI_Client_Embedding_Builder::class, $received_builder_type ); + } + + /** + * Test that the constructor allows overriding the default request timeout. + * + * @ticket 64591 + */ + public function test_constructor_allows_overriding_request_timeout() { + add_filter( + 'wp_ai_client_default_request_timeout', + static function () { + return 45.5; + } + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertSame( 45.5, $request_options->getTimeout() ); + } + + /** + * Test that the constructor rejects an invalid request timeout, reporting the concrete class name. + * + * @ticket 64591 + * + * @expectedIncorrectUsage WP_AI_Client_Embedding_Builder::__construct + */ + public function test_constructor_disallows_overriding_with_invalid_request_timeout() { + add_filter( 'wp_ai_client_default_request_timeout', '__return_null' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertSame( 30.0, $request_options->getTimeout() ); + } + + /** + * Test that fluent methods return the wrapper instance for method chaining. + * + * @ticket 64591 + */ + public function test_method_chaining_returns_wrapper() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $result = $builder->with_input( 'Test input' ); + $this->assertSame( $builder, $result, 'with_input() should return the wrapper instance' ); + + $result = $builder->using_dimensions( 256 ); + $this->assertSame( $builder, $result, 'using_dimensions() should return the wrapper instance' ); + } + + /** + * Test that with_input() appends inputs to the wrapped builder. + * + * @ticket 64591 + */ + public function test_with_input_appends_inputs() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'First' ); + $builder->with_input( 'Second', 'Third' ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 3, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } + + /** + * Test that using_dimensions() sets the dimensions on the model configuration. + * + * @ticket 64591 + */ + public function test_using_dimensions_sets_model_config() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + $builder->using_dimensions( 256 ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_builder_property_value( $builder, 'modelConfig' ); + + $this->assertSame( 256, $config->getDimensions() ); + } + + /** + * Test that the wrapper passes the AiClient event dispatcher to the wrapped builder. + * + * @ticket 64591 + */ + public function test_passes_ai_client_event_dispatcher_to_wrapped_builder() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $dispatcher = $this->get_wrapped_builder_property_value( $builder, 'eventDispatcher' ); + + $this->assertSame( AiClient::getEventDispatcher(), $dispatcher ); + } + + /** + * Test that calling a nonexistent method puts the builder in an error state. + * + * @ticket 64591 + */ + public function test_invalid_method_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $builder->nonexistent_method(); + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_builder_error', $result->get_error_code() ); + } + + /** + * Test that once in error state, subsequent fluent calls return the same instance. + * + * @ticket 64591 + */ + public function test_error_state_fluent_calls_return_same_instance() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + // Trigger an error state by calling a nonexistent method. + $builder->nonexistent_method(); + + $result = $builder->with_input( 'Test' ); + $this->assertSame( $builder, $result, 'Fluent method should return same instance when in error state' ); + + $result = $builder->using_dimensions( 256 ); + $this->assertSame( $builder, $result, 'Fluent method should return same instance when in error state' ); + } + + /** + * Test that is_supported() returns false when in error state. + * + * @ticket 64591 + */ + public function test_support_check_method_returns_false_in_error_state() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + // Trigger an error state by calling a nonexistent method. + $builder->nonexistent_method(); + + $this->assertFalse( $builder->is_supported(), 'is_supported should return false when in error state' ); + } + + /** + * Test that an invalid constructor input puts the builder in an error state. + * + * @ticket 64591 + */ + public function test_constructor_with_invalid_input_enters_error_state() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, '' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that generating without any inputs returns a WP_Error. + * + * @ticket 64591 + */ + public function test_generate_embeddings_without_input_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that is_supported() returns false when AI is not supported. + * + * @ticket 64591 + */ + public function test_is_supported_returns_false_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that is_supported() returns false when the prevent embedding filter returns true. + * + * @ticket 64591 + */ + public function test_is_supported_returns_false_when_filter_prevents_embedding() { + add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that is_supported() returns true for a model with the embedding generation capability. + * + * @ticket 64591 + */ + public function test_is_supported_with_capable_model() { + $model = $this->create_mock_embedding_model( $this->create_test_embedding_result( 1 ) ); + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $this->assertTrue( $builder->using_model( $model )->is_supported() ); + } + + /** + * Test that generating returns a WP_Error when the prevent embedding filter returns true. + * + * @ticket 64591 + */ + public function test_generate_embeddings_returns_wp_error_when_filter_prevents_embedding() { + add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_prevented', $result->get_error_code() ); + $this->assertSame( 'Embedding generation was prevented by a filter.', $result->get_error_message() ); + $this->assertSame( 503, $result->get_error_data()['status'] ); + } + + /** + * Test that generating returns a WP_Error when AI is not supported. + * + * @ticket 64591 + */ + public function test_generate_embeddings_returns_wp_error_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_prevented', $result->get_error_code() ); + $this->assertSame( 'AI features are not supported in this environment.', $result->get_error_message() ); + } + + /** + * Test that the prevent embedding filter receives a clone of the builder instance. + * + * @ticket 64591 + */ + public function test_prevent_embedding_filter_receives_cloned_builder_instance() { + $captured_builder = null; + + add_filter( + 'wp_ai_client_prevent_embedding', + static function ( $prevent, $builder ) use ( &$captured_builder ) { + $captured_builder = $builder; + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + // Test with is_supported(). + $builder->is_supported(); + $this->assertNotSame( $builder, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $captured_builder ); + + // Reset and test with generate_embeddings(). + $captured_builder = null; + $builder2 = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + $builder2->generate_embeddings(); + $this->assertNotSame( $builder2, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $captured_builder ); + } + + /** + * Test that generate_embedding_result() returns the result from the model. + * + * @ticket 64591 + */ + public function test_generate_embedding_result() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Test that generate_embedding() returns a single embedding. + * + * @ticket 64591 + */ + public function test_generate_embedding() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $embedding = $builder->generate_embedding(); + + $this->assertInstanceOf( Embedding::class, $embedding ); + $this->assertSame( $result->getEmbedding(), $embedding ); + } + + /** + * Test that generate_embeddings() returns one embedding per input. + * + * @ticket 64591 + */ + public function test_generate_embeddings() { + $result = $this->create_test_embedding_result( 2 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + $builder->using_model( $model ); + + $embeddings = $builder->generate_embeddings(); + + $this->assertIsArray( $embeddings ); + $this->assertCount( 2, $embeddings ); + $this->assertSame( $result->getEmbeddings(), $embeddings ); + } + + /** + * Test that generate_embedding() returns a WP_Error when multiple inputs are configured. + * + * @ticket 64591 + */ + public function test_generate_embedding_returns_wp_error_with_multiple_inputs() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + + $result = $builder->generate_embedding(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that a mismatched embedding count from the model results in a WP_Error. + * + * @ticket 64591 + */ + public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + $builder->using_model( $model ); + + $error = $builder->generate_embeddings(); + + $this->assertWPError( $error ); + $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); + } + + /** + * Test that exception_to_wp_error() maps exceptions to embedding-prefixed error codes. + * + * @ticket 64591 + * + * @dataProvider data_exception_to_wp_error_mapping + * + * @param Exception $exception The exception to convert. + * @param string $expected_code The expected WP_Error code. + * @param int $expected_status The expected HTTP status in the error data. + */ + public function test_exception_to_wp_error_mapping( Exception $exception, string $expected_code, int $expected_status ) { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + $error = $this->invoke_exception_to_wp_error( $builder, $exception ); + + $this->assertSame( $expected_code, $error->get_error_code() ); + $this->assertSame( $exception->getMessage(), $error->get_error_message() ); + $this->assertSame( $expected_status, $error->get_error_data()['status'] ); + $this->assertSame( get_class( $exception ), $error->get_error_data()['exception_class'] ); + } + + /** + * Data provider for {@see self::test_exception_to_wp_error_mapping()}. + * + * @return array + */ + public static function data_exception_to_wp_error_mapping(): array { + return array( + 'NetworkException' => array( new NetworkException( 'network error' ), 'embedding_network_error', 503 ), + 'ClientException with code' => array( new ClientException( 'unauthorized', 401 ), 'embedding_client_error', 401 ), + 'ClientException without code' => array( new ClientException( 'bad request' ), 'embedding_client_error', 400 ), + 'ServerException with code' => array( new ServerException( 'bad gateway', 502 ), 'embedding_upstream_server_error', 502 ), + 'ServerException without code' => array( new ServerException( 'server error' ), 'embedding_upstream_server_error', 500 ), + 'TokenLimitReachedException' => array( new TokenLimitReachedException( 'token limit' ), 'embedding_token_limit_reached', 400 ), + 'InvalidArgumentException' => array( new AiClientInvalidArgumentException( 'invalid arg' ), 'embedding_invalid_argument', 400 ), + 'generic Exception' => array( new Exception( 'generic' ), 'embedding_builder_error', 500 ), + ); + } +} From 316ca31cafab3cd73cd0d9236c85ef4d14d9481c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:14:40 +0000 Subject: [PATCH 07/15] AI: Port recent prompt builder fixes to WP_AI_Client_Builder. The base class extracted from `WP_AI_Client_Prompt_Builder` predated three fixes that landed on trunk for the prompt builder, so they were lost in the refactor and missing from the new embedding builder: * Catch `Throwable` instead of `Exception` in the constructor and `__call()`, so a `TypeError` from the strict-typed SDK (e.g. `using_dimensions( array( 256 ) )`) becomes a `WP_Error` instead of a fatal error. `exception_to_wp_error()` is renamed to `throwable_to_wp_error()` to match. See #65505. * Return `false` from support check methods when the wrapped builder throws. See #65781. * Clone the wrapped builder and any stored error in `__clone()`, so the clone passed to the prevent filters cannot mutate the original. See #65782. Adds matching regression tests for the embedding builder. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../ai-client/class-wp-ai-client-builder.php | 60 +++++--- .../ai-client/wpAiClientEmbeddingBuilder.php | 128 ++++++++++++++++-- .../ai-client/wpAiClientPromptBuilder.php | 2 +- 3 files changed, 155 insertions(+), 35 deletions(-) diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php index 28cfb9dac4ab8..b48d4753df2d0 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -27,7 +27,9 @@ * interface. As soon as any exception is caught in a chain of method calls, * the returned instance will be in an error state, and all subsequent method * calls will be no-ops that just return the same error state instance. Only - * when a generating method is called, the WP_Error will be returned. + * when a generating method is called, the WP_Error will be returned. The + * support check methods are the exception to the no-op behavior: they return + * false rather than the error state instance. * * @since 7.1.0 */ @@ -65,9 +67,9 @@ abstract class WP_AI_Client_Builder { public function __construct( ProviderRegistry $registry, $input = null ) { try { $this->builder = $this->create_sdk_builder( $registry, $input ); - } catch ( Exception $e ) { + } catch ( Throwable $e ) { $this->builder = $this->create_sdk_builder( $registry, null ); - $this->error = $this->exception_to_wp_error( $e ); + $this->error = $this->throwable_to_wp_error( $e ); } $default_timeout = 30.0; @@ -106,6 +108,23 @@ public function __construct( ProviderRegistry $registry, $input = null ) { ); } + /** + * Clones the wrapped builder alongside this instance. + * + * The wrapped builder mutates its own state, so without this a clone would + * share that state with the original and any change made to one would be + * visible in the other. + * + * @since 7.2.0 + */ + public function __clone() { + $this->builder = clone $this->builder; + + if ( null !== $this->error ) { + $this->error = clone $this->error; + } + } + /** * Creates the wrapped builder instance from the PHP AI Client SDK. * @@ -174,7 +193,7 @@ abstract protected function get_support_check_methods(): array; * * This allows WordPress developers to use snake_case naming conventions. It catches * any exceptions thrown, stores them, and returns a WP_Error when a terminate method - * is called. + * is called, or false when a support check method is called. * * @since 7.0.0 * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. @@ -243,47 +262,50 @@ public function __call( string $name, array $arguments ) { } return $result; - } catch ( Exception $e ) { - $this->error = $this->exception_to_wp_error( $e ); + } catch ( Throwable $e ) { + $this->error = $this->throwable_to_wp_error( $e ); if ( $this->is_generating_method( $name ) ) { return $this->error; } + if ( $this->is_support_check_method( $name ) ) { + return false; + } return $this; } } /** - * Converts an exception into a WP_Error with a structured error code and message. + * Converts a throwable into a WP_Error with a structured error code and message. * - * This method maps different exception types to specific WP_Error codes and HTTP status codes. + * This method maps different throwable types to specific WP_Error codes and HTTP status codes. * The presence of the status codes means these WP_Error objects can be easily used in REST API responses * or other contexts where HTTP semantics are relevant. * * @since 7.0.0 * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * - * @param Exception $e The exception to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error object. */ - protected function exception_to_wp_error( Exception $e ): WP_Error { + protected function throwable_to_wp_error( Throwable $throwable ): WP_Error { $prefix = $this->get_error_code_prefix(); - if ( $e instanceof NetworkException ) { + if ( $throwable instanceof NetworkException ) { $error_code = $prefix . '_network_error'; $status_code = 503; - } elseif ( $e instanceof ClientException ) { + } elseif ( $throwable instanceof ClientException ) { // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = $prefix . '_client_error'; - $status_code = $e->getCode() ? $e->getCode() : 400; - } elseif ( $e instanceof ServerException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 400; + } elseif ( $throwable instanceof ServerException ) { // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = $prefix . '_upstream_server_error'; - $status_code = $e->getCode() ? $e->getCode() : 500; - } elseif ( $e instanceof TokenLimitReachedException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 500; + } elseif ( $throwable instanceof TokenLimitReachedException ) { $error_code = $prefix . '_token_limit_reached'; $status_code = 400; - } elseif ( $e instanceof InvalidArgumentException ) { + } elseif ( $throwable instanceof InvalidArgumentException ) { $error_code = $prefix . '_invalid_argument'; $status_code = 400; } else { @@ -293,10 +315,10 @@ protected function exception_to_wp_error( Exception $e ): WP_Error { return new WP_Error( $error_code, - $e->getMessage(), + $throwable->getMessage(), array( 'status' => $status_code, - 'exception_class' => get_class( $e ), + 'exception_class' => get_class( $throwable ), ) ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index 2a8b0c15e928c..e7bf9bbbb485d 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -8,6 +8,7 @@ */ use WordPress\AiClient\AiClient; +use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException as AiClientInvalidArgumentException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -91,18 +92,33 @@ private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Buil } /** - * Invokes the protected exception_to_wp_error() method on a builder. + * Invokes the protected throwable_to_wp_error() method on a builder. * * @param WP_AI_Client_Embedding_Builder $builder The builder to invoke the method on. - * @param Exception $exception The exception to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error. */ - private function invoke_exception_to_wp_error( WP_AI_Client_Embedding_Builder $builder, Exception $exception ): WP_Error { + private function invoke_throwable_to_wp_error( WP_AI_Client_Embedding_Builder $builder, Throwable $throwable ): WP_Error { $reflection = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); - $method = $reflection->getMethod( 'exception_to_wp_error' ); + $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method ); - return $method->invoke( $builder, $exception ); + return $method->invoke( $builder, $throwable ); + } + + /** + * Returns the inputs held by the wrapped embedding builder. + * + * @param WP_AI_Client_Embedding_Builder $builder The embedding builder to read. + * @return string[] The text of each input, in order. + */ + private function get_input_texts( WP_AI_Client_Embedding_Builder $builder ): array { + $texts = array(); + foreach ( $this->get_wrapped_builder_property_value( $builder, 'inputs' ) as $part ) { + $texts[] = (string) $part->getText(); + } + + return $texts; } /** @@ -654,32 +670,32 @@ public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { } /** - * Test that exception_to_wp_error() maps exceptions to embedding-prefixed error codes. + * Test that throwable_to_wp_error() maps throwables to embedding-prefixed error codes. * * @ticket 64591 * - * @dataProvider data_exception_to_wp_error_mapping + * @dataProvider data_throwable_to_wp_error_mapping * - * @param Exception $exception The exception to convert. + * @param Throwable $throwable The throwable to convert. * @param string $expected_code The expected WP_Error code. * @param int $expected_status The expected HTTP status in the error data. */ - public function test_exception_to_wp_error_mapping( Exception $exception, string $expected_code, int $expected_status ) { + public function test_throwable_to_wp_error_mapping( Throwable $throwable, string $expected_code, int $expected_status ) { $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( $builder, $exception ); + $error = $this->invoke_throwable_to_wp_error( $builder, $throwable ); $this->assertSame( $expected_code, $error->get_error_code() ); - $this->assertSame( $exception->getMessage(), $error->get_error_message() ); + $this->assertSame( $throwable->getMessage(), $error->get_error_message() ); $this->assertSame( $expected_status, $error->get_error_data()['status'] ); - $this->assertSame( get_class( $exception ), $error->get_error_data()['exception_class'] ); + $this->assertSame( get_class( $throwable ), $error->get_error_data()['exception_class'] ); } /** - * Data provider for {@see self::test_exception_to_wp_error_mapping()}. + * Data provider for {@see self::test_throwable_to_wp_error_mapping()}. * - * @return array + * @return array */ - public static function data_exception_to_wp_error_mapping(): array { + public static function data_throwable_to_wp_error_mapping(): array { return array( 'NetworkException' => array( new NetworkException( 'network error' ), 'embedding_network_error', 503 ), 'ClientException with code' => array( new ClientException( 'unauthorized', 401 ), 'embedding_client_error', 401 ), @@ -689,6 +705,88 @@ public static function data_exception_to_wp_error_mapping(): array { 'TokenLimitReachedException' => array( new TokenLimitReachedException( 'token limit' ), 'embedding_token_limit_reached', 400 ), 'InvalidArgumentException' => array( new AiClientInvalidArgumentException( 'invalid arg' ), 'embedding_invalid_argument', 400 ), 'generic Exception' => array( new Exception( 'generic' ), 'embedding_builder_error', 500 ), + 'TypeError' => array( new TypeError( 'type error' ), 'embedding_builder_error', 500 ), ); } + + /** + * Test that a TypeError thrown by the wrapped SDK is caught and returned as a WP_Error. + * + * `usingDimensions()` expects an int; an array can never be coerced and throws a + * TypeError, which extends Error rather than Exception. + * + * @ticket 65505 + */ + public function test_call_catches_type_error_from_invalid_argument_type() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $result = $builder->using_dimensions( array( 256 ) ); + + $this->assertSame( $builder, $result, 'The fluent interface should be preserved' ); + + $error = $result->generate_embeddings(); + $this->assertWPError( $error ); + $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); + $this->assertSame( 'TypeError', $error->get_error_data()['exception_class'] ); + } + + /** + * Test that is_supported() returns false when the wrapped builder throws. + * + * @ticket 65781 + */ + public function test_is_supported_returns_false_when_builder_throws() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $wrapped_builder = $this->createMock( EmbeddingBuilder::class ); + $wrapped_builder->method( 'isSupported' ) + ->willThrowException( new RuntimeException( 'Thrown by the wrapped builder.' ) ); + + $builder_property = new ReflectionProperty( WP_AI_Client_Embedding_Builder::class, 'builder' ); + self::set_accessible( $builder_property ); + $builder_property->setValue( $builder, $wrapped_builder ); + + $this->assertFalse( $builder->is_supported(), 'is_supported should return false when the wrapped builder throws' ); + + // The error is still recorded, so a generating method returns it. + $error = $builder->generate_embeddings(); + $this->assertWPError( $error ); + $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); + } + + /** + * Test that a clone does not share the wrapped builder with the original. + * + * @ticket 65782 + */ + public function test_clone_does_not_share_the_wrapped_builder() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Original input' ); + $clone = clone $builder; + + $clone->with_input( 'Added to the clone' ); + + $this->assertSame( array( 'Original input' ), $this->get_input_texts( $builder ), 'Changing the clone should not change the original' ); + } + + /** + * Test that the clone passed to the prevent embedding filter cannot change the inputs. + * + * @ticket 65782 + */ + public function test_prevent_embedding_filter_cannot_mutate_the_original_inputs() { + add_filter( + 'wp_ai_client_prevent_embedding', + static function ( $prevent, $builder ) { + $builder->with_input( 'Added by the filter' ); + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Original input' ); + $builder->is_supported(); + + $this->assertSame( array( 'Original input' ), $this->get_input_texts( $builder ), 'The filter should not change the original inputs' ); + } } diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 16a3d5366b4ba..2129dfe641db5 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2938,7 +2938,7 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat } /** - * Invokes the private throwable_to_wp_error method via reflection. + * Invokes the protected throwable_to_wp_error method via reflection. * * @param WP_AI_Client_Prompt_Builder $builder The builder instance. * @param Throwable $throwable The throwable to convert. From 524974d1a705a15c4530c6f10ccb8d47df75c7f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:14:40 +0000 Subject: [PATCH 08/15] AI: Update `@since` tags from 7.1.0 to 7.2.0. Trunk is now 7.2-alpha, so the embedding builder and base class will ship in 7.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- src/wp-includes/ai-client.php | 2 +- .../ai-client/class-wp-ai-client-builder.php | 36 +++++++++---------- .../class-wp-ai-client-embedding-builder.php | 22 ++++++------ .../class-wp-ai-client-prompt-builder.php | 14 ++++---- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 74ca766e22285..d8fd16c270af7 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -72,7 +72,7 @@ function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { * complex types. Pass multiple arguments to embed multiple inputs at once, each * producing its own embedding vector. * - * @since 7.1.0 + * @since 7.2.0 * * @param string|MessagePart|File|array ...$input Optional. Initial input(s) to embed. * A string for simple text inputs, diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php index b48d4753df2d0..a8a95e448eee2 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 7.1.0 + * @since 7.2.0 */ use WordPress\AiClient\Common\Exception\InvalidArgumentException; @@ -31,7 +31,7 @@ * support check methods are the exception to the no-op behavior: they return * false rather than the error state instance. * - * @since 7.1.0 + * @since 7.2.0 */ abstract class WP_AI_Client_Builder { @@ -39,7 +39,7 @@ abstract class WP_AI_Client_Builder { * Wrapped builder instance from the PHP AI Client SDK. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * @var object */ protected object $builder; @@ -48,7 +48,7 @@ abstract class WP_AI_Client_Builder { * WordPress error instance, if any error occurred during method calls. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * @var WP_Error|null */ protected ?WP_Error $error = null; @@ -57,7 +57,7 @@ abstract class WP_AI_Client_Builder { * Constructor. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param mixed $input Optional. Initial input content for the builder. @@ -78,7 +78,7 @@ public function __construct( ProviderRegistry $registry, $input = null ) { * Filters the default request timeout in seconds for AI Client HTTP requests. * * @since 7.0.0 - * @since 7.1.0 Added the `$builder_type` parameter. + * @since 7.2.0 Added the `$builder_type` parameter. * * @param float $default_timeout The default timeout in seconds. * @param string $builder_type The class name of the builder the timeout applies to, @@ -128,7 +128,7 @@ public function __clone() { /** * Creates the wrapped builder instance from the PHP AI Client SDK. * - * @since 7.1.0 + * @since 7.2.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param mixed $input Initial input content for the builder, or null. @@ -139,7 +139,7 @@ abstract protected function create_sdk_builder( ProviderRegistry $registry, $inp /** * Retrieves the prefix used for WP_Error codes created by this builder. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The error code prefix, e.g. 'prompt' or 'embedding'. */ @@ -148,7 +148,7 @@ abstract protected function get_error_code_prefix(): string; /** * Retrieves the error message to use when execution is prevented by a filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The translated error message. */ @@ -160,7 +160,7 @@ abstract protected function get_prevented_error_message(): string; * Child classes apply their specific filter, passing a clone of the builder * instance for read-only inspection. * - * @since 7.1.0 + * @since 7.2.0 * * @return bool Whether execution is prevented. */ @@ -171,7 +171,7 @@ abstract protected function is_prevented_by_filter(): bool; * * Structured as a map of method name to true for faster lookups. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The generating methods map. */ @@ -182,7 +182,7 @@ abstract protected function get_generating_methods(): array; * * Structured as a map of method name to true for faster lookups. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The support check methods map. */ @@ -196,7 +196,7 @@ abstract protected function get_support_check_methods(): array; * is called, or false when a support check method is called. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * * @param string $name The method name in snake_case. * @param array $arguments The method arguments. @@ -283,7 +283,7 @@ public function __call( string $name, array $arguments ) { * or other contexts where HTTP semantics are relevant. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error object. @@ -327,7 +327,7 @@ protected function throwable_to_wp_error( Throwable $throwable ): WP_Error { * Checks if a method name is a support check method (is_supported*). * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, * and changed from static to instance method. * * @param string $name The method name. @@ -342,7 +342,7 @@ private function is_support_check_method( string $name ): bool { * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, * and changed from static to instance method. * * @param string $name The method name. @@ -357,7 +357,7 @@ private function is_generating_method( string $name ): bool { * Retrieves a callable for a given PHP AI Client SDK builder method name. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * * @param string $name The method name in snake_case. * @return callable The callable for the specified method. @@ -386,7 +386,7 @@ protected function get_builder_callable( string $name ): callable { * Converts snake_case to camelCase. * * @since 7.0.0 - * @since 7.1.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * * @param string $snake_case The snake_case string. * @return string The camelCase string. diff --git a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php index a5ab7cef9040a..5500d8f836b8c 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 7.1.0 + * @since 7.2.0 */ use WordPress\AiClient\AiClient; @@ -30,7 +30,7 @@ * calls will be no-ops that just return the same error state instance. Only * when a generating method is called, the WP_Error will be returned. * - * @since 7.1.0 + * @since 7.2.0 * * @see WP_AI_Client_Builder * @@ -55,7 +55,7 @@ class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder { * * Structured as a map for faster lookups. * - * @since 7.1.0 + * @since 7.2.0 * @var array */ private static array $generating_methods = array( @@ -69,7 +69,7 @@ class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder { * * Structured as a map for faster lookups. * - * @since 7.1.0 + * @since 7.2.0 * @var array */ private static array $support_check_methods = array( @@ -79,7 +79,7 @@ class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder { /** * Creates the wrapped embedding builder instance from the PHP AI Client SDK. * - * @since 7.1.0 + * @since 7.2.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param EmbeddingInput|list|null $input Initial input(s) to embed, or null. @@ -98,7 +98,7 @@ protected function create_sdk_builder( ProviderRegistry $registry, $input ): obj /** * Retrieves the prefix used for WP_Error codes created by this builder. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The error code prefix. */ @@ -109,7 +109,7 @@ protected function get_error_code_prefix(): string { /** * Retrieves the error message to use when the embedding generation is prevented by a filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The translated error message. */ @@ -120,7 +120,7 @@ protected function get_prevented_error_message(): string { /** * Checks whether the embedding generation is prevented by the `wp_ai_client_prevent_embedding` filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return bool Whether the embedding generation is prevented. */ @@ -128,7 +128,7 @@ protected function is_prevented_by_filter(): bool { /** * Filters whether to prevent the embedding generation from being executed. * - * @since 7.1.0 + * @since 7.2.0 * * @param bool $prevent Whether to prevent the embedding generation. Default false. * @param WP_AI_Client_Embedding_Builder $builder A clone of the embedding builder instance (read-only). @@ -139,7 +139,7 @@ protected function is_prevented_by_filter(): bool { /** * Retrieves the methods that generate embeddings from the inputs. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The generating methods map. */ @@ -150,7 +150,7 @@ protected function get_generating_methods(): array { /** * Retrieves the methods that check whether the embedding generation is supported. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The support check methods map. */ diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index e2c8124afd299..c793ef77517c5 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -43,7 +43,7 @@ * false rather than the error state instance. * * @since 7.0.0 - * @since 7.1.0 Now extends the `WP_AI_Client_Builder` base class. + * @since 7.2.0 Now extends the `WP_AI_Client_Builder` base class. * * @see WP_AI_Client_Builder * @@ -209,7 +209,7 @@ public function using_abilities( ...$abilities ): self { /** * Creates the wrapped prompt builder instance from the PHP AI Client SDK. * - * @since 7.1.0 + * @since 7.2.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param Prompt $prompt Initial prompt content, or null. @@ -228,7 +228,7 @@ protected function create_sdk_builder( ProviderRegistry $registry, $prompt ): ob /** * Retrieves the prefix used for WP_Error codes created by this builder. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The error code prefix. */ @@ -239,7 +239,7 @@ protected function get_error_code_prefix(): string { /** * Retrieves the error message to use when the prompt is prevented by a filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return string The translated error message. */ @@ -250,7 +250,7 @@ protected function get_prevented_error_message(): string { /** * Checks whether the prompt is prevented by the `wp_ai_client_prevent_prompt` filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return bool Whether the prompt is prevented. */ @@ -269,7 +269,7 @@ protected function is_prevented_by_filter(): bool { /** * Retrieves the methods that generate a result from the prompt. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The generating methods map. */ @@ -280,7 +280,7 @@ protected function get_generating_methods(): array { /** * Retrieves the methods that check whether the prompt is supported. * - * @since 7.1.0 + * @since 7.2.0 * * @return array The support check methods map. */ From 14fdd4bcfe68cf99eb0f3cf4cef1fe0f38f365e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:44:57 +0000 Subject: [PATCH 09/15] AI: Update bundled PHP AI Client library to 1.5.0. Regenerated via `bash tools/php-ai-client/installer.sh --version=1.5.0`. The 1.5.0 release changes `EmbeddingBuilder` to never select a model automatically. Embedding vectors are only comparable to vectors from the same model, so a stored corpus is bound to the model that created it, and automatic selection could silently invalidate existing vectors. A model must now be specified via `usingModel()` or the new `usingProviderModel()`, and `isSupported()` checks that the specified model can fulfill the request. `usingProvider()` and `usingModelPreference()` are no longer available on `EmbeddingBuilder`. Full changelog: https://github.com/WordPress/php-ai-client/releases/tag/1.5.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../php-ai-client/src/AiClient.php | 78 ++-- .../src/Builders/EmbeddingBuilder.php | 334 ++++++++++++++++-- .../Traits/ModelConfigurationTrait.php | 56 +++ .../Builders/Traits/ModelResolutionTrait.php | 48 +-- ...AbstractApiBasedModelMetadataDirectory.php | 58 ++- .../src/Providers/ModelResolver.php | 12 +- .../Models/DTO/ModelRequirements.php | 42 ++- .../src/Tools/DTO/FunctionDeclaration.php | 33 +- 8 files changed, 555 insertions(+), 106 deletions(-) create mode 100644 src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index 55bda55b59d9c..4ee6ec969f34d 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -11,6 +11,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; @@ -80,6 +81,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 */ @@ -88,7 +90,7 @@ class AiClient /** * @var string The version of the AI Client. */ - public const VERSION = '1.4.0'; + public const VERSION = '1.5.0'; /** * @var ProviderRegistry|null The default provider registry instance. */ @@ -222,6 +224,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. @@ -358,55 +364,59 @@ public static function generateVideoResult($prompt, $modelOrConfig = null, ?Prov /** * 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|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. * - * @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, ?ProviderRegistry $registry = null): EmbeddingResult + public static function generateEmbeddingResult($input, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): EmbeddingResult { - self::validateModelOrConfigParameter($modelOrConfig); - return self::applyModelOrConfig(self::input($input, $registry), $modelOrConfig)->generateEmbeddingResult(); + 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|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. */ - public static function generateEmbedding($input, $modelOrConfig = null, ?ProviderRegistry $registry = null): Embedding + public static function generateEmbedding($input, $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|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. */ - public static function generateEmbeddings(array $inputs, $modelOrConfig = null, ?ProviderRegistry $registry = null): array + public static function generateEmbeddings(array $inputs, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): array { - self::validateModelOrConfigParameter($modelOrConfig); - return self::applyModelOrConfig(self::input($inputs, $registry), $modelOrConfig)->generateEmbeddings(); + return self::getConfiguredEmbeddingBuilder($inputs, $model, $modelConfig, $registry)->generateEmbeddings(); } /** * Creates a new message builder for fluent API usage. @@ -451,13 +461,39 @@ private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?Pro { 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, 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. + * @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); + // The builder's configuration takes precedence over the model's own configuration. + 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/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php index d822fa48eb15e..a23a740ea277a 100644 --- a/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php @@ -4,16 +4,22 @@ namespace WordPress\AiClient\Builders; use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; -use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; +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; use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent; use WordPress\AiClient\Files\DTO\File; 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; +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; @@ -24,22 +30,50 @@ * * 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-import-type ProviderModelTuple from ModelResolver * * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape */ class EmbeddingBuilder { - use ModelResolutionTrait; + 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. + */ + 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 ProviderModelTuple|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. */ @@ -49,14 +83,14 @@ 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. */ public function __construct(ProviderRegistry $registry, $input = null, ?EventDispatcherInterface $eventDispatcher = null) { + $this->registry = $registry; $this->modelConfig = new ModelConfig(); - $this->modelResolver = new ModelResolver($registry); $this->eventDispatcher = $eventDispatcher; if ($input === null) { return; @@ -72,8 +106,8 @@ public function __construct(ProviderRegistry $registry, $input = null, ?EventDis /** * 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 */ @@ -85,9 +119,11 @@ 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. } /** * Adds one or more inputs to embed. @@ -108,6 +144,64 @@ 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. 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 + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + $this->providerModel = null; + 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 1.5.0 + * + * @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. * @@ -122,16 +216,44 @@ public function usingDimensions(int $dimensions): self return $this; } /** - * Checks whether the current inputs and configuration are supported by an available model. + * Checks whether the specified model supports the current inputs and configuration. + * + * 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. + * + * Any reason the specified model cannot fulfill the request is reported as `false`, including an + * 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 + * require a request to the provider, which is cached for the remainder of the request. * * @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. */ public function isSupported(): bool { - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); - return $this->modelResolver->isSupported($requirements); + if ($this->providerModel === null && $this->model === null) { + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + try { + $model = $this->locateModel(); + } 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; + } + if (!$model instanceof EmbeddingGenerationModelInterface) { + return \false; + } + return $this->describeUnmetRequirements($model) === null; } /** * Generates an embedding result from the configured inputs. @@ -139,9 +261,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 { @@ -149,11 +272,7 @@ public function generateEmbeddingResult(): EmbeddingResult throw new InvalidArgumentException('Cannot generate embeddings from empty input. Add content using withInput().'); } $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)); $result = $model->generateEmbeddingResult($this->inputs); // Embeddings map positionally to inputs, so the counts must match. @@ -169,8 +288,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 { @@ -185,14 +305,172 @@ 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 1.5.0 + * + * @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; + } + /** + * 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 1.5.0 + * + * @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 locateModel(): ModelInterface + { + if ($this->providerModel !== null) { + [$providerIdOrClassName, $modelId] = $this->providerModel; + $this->assertProviderConfigured($providerIdOrClassName); + // Retrieving the model also binds its provider dependencies. + return $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); + } + if ($this->model !== null) { + $model = $this->model; + $this->assertProviderConfigured($model->providerMetadata()->getId()); + return $model; + } + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + /** + * Prepares the specified model for use, without verifying that it can fulfill the request. + * + * @since 1.5.0 + * + * @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); + } + // 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; + } + /** + * 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 1.5.0 + * + * @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. + * + * @since 1.5.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + * @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 is not configured with valid credentials. ' . 'Ensure the provider is registered and its credentials are present and valid ' . 'before generating embeddings.', $providerIdOrClassName)); + } + } + /** + * Describes the requirements of the current request that the given model does not meet. + * + * @since 1.5.0 + * + * @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->effectiveModelConfig($model)); + /** @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 1.5.0 + * + * @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/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php new file mode 100644 index 0000000000000..1718a81f222cf --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php @@ -0,0 +1,56 @@ +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 1.5.0 + * + * @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/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php index bce8e00045223..a04abcb23c9fa 100644 --- a/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php +++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php @@ -7,23 +7,22 @@ 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 + * + * @phpstan-import-type ProviderModelTuple from ModelResolver */ trait ModelResolutionTrait { - /** - * @var ModelConfig The model configuration. - */ - protected ModelConfig $modelConfig; + use \WordPress\AiClient\Builders\Traits\ModelConfigurationTrait; /** * @var ModelResolver The resolver that owns model selection state and logic. */ @@ -43,10 +42,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; } /** @@ -54,7 +50,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. @@ -67,28 +63,6 @@ public function usingModelPreference(...$preferredModels): self $this->modelResolver->setModelPreferences(...$preferredModels); 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. * diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 4f7e2a338fabc..2989f326d6676 100644 --- a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -31,6 +31,14 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi * @var string */ private const MODELS_CACHE_KEY = 'models'; + /** + * Request-local cache for explicit model metadata lookups. + * + * @since 1.5.0 + * + * @var array + */ + private array $explicitModelMetadataCache = []; /** * {@inheritDoc} * @@ -39,7 +47,8 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi final public function listModelMetadata(): array { $modelsMetadata = $this->getModelMetadataMap(); - return array_values($modelsMetadata); + $explicitModelsMetadata = $this->getExplicitModelMetadataMap(array_keys($modelsMetadata)); + return array_values(array_replace($modelsMetadata, $explicitModelsMetadata)); } /** * {@inheritDoc} @@ -48,6 +57,9 @@ final public function listModelMetadata(): array */ final public function hasModelMetadata(string $modelId): bool { + if (isset($this->getExplicitModelMetadataMap([$modelId])[$modelId])) { + return \true; + } $modelsMetadata = $this->getModelMetadataMap(); return isset($modelsMetadata[$modelId]); } @@ -58,12 +70,40 @@ final public function hasModelMetadata(string $modelId): bool */ final public function getModelMetadata(string $modelId): ModelMetadata { + $explicitModelsMetadata = $this->getExplicitModelMetadataMap([$modelId]); + if (isset($explicitModelsMetadata[$modelId])) { + return $explicitModelsMetadata[$modelId]; + } $modelsMetadata = $this->getModelMetadataMap(); if (!isset($modelsMetadata[$modelId])) { throw new InvalidArgumentException(sprintf('No model with ID %s was found in the provider', $modelId)); } return $modelsMetadata[$modelId]; } + /** + * Gets explicit model metadata using request-local memoization. + * + * @since 1.5.0 + * + * @param list $modelIds Model IDs for which to get explicit metadata. + * @return array Map of model ID to explicit model metadata. + */ + private function getExplicitModelMetadataMap(array $modelIds): array + { + $uncheckedModelIds = []; + foreach ($modelIds as $modelId) { + if (!array_key_exists($modelId, $this->explicitModelMetadataCache)) { + $uncheckedModelIds[] = $modelId; + } + } + if ($uncheckedModelIds) { + $explicitModelsMetadata = $this->createModelMetadataForExplicitModelIds($uncheckedModelIds); + foreach ($uncheckedModelIds as $modelId) { + $this->explicitModelMetadataCache[$modelId] = $explicitModelsMetadata[$modelId] ?? null; + } + } + return array_filter(array_intersect_key($this->explicitModelMetadataCache, array_flip($modelIds))); + } /** * Returns the map of model ID to model metadata for all models from the provider. * @@ -94,6 +134,22 @@ protected function getBaseCacheKey(): string { return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class); } + /** + * Creates metadata for explicit model IDs without listing provider models. + * + * Providers whose APIs accept arbitrary/current model IDs can override this to avoid a live list-models request + * when callers already know the model ID they want to instantiate. + * + * @since 1.5.0 + * + * @param list $modelIds The explicit model IDs. + * @return array Map of model ID to model metadata. Omit IDs that should fall back to the + * provider models list. + */ + protected function createModelMetadataForExplicitModelIds(array $modelIds): array + { + return []; + } /** * Sends the API request to list models from the provider and returns the map of model ID to model metadata. * diff --git a/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php index 88862ed96972b..975a2bb3d7694 100644 --- a/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php +++ b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php @@ -19,6 +19,12 @@ * selection behaves identically regardless of what is being generated. * * @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 { @@ -96,7 +102,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. @@ -277,7 +283,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 { @@ -306,7 +312,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 { diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php index d1ab1d3e6c943..3d45c525f5b9b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php @@ -25,6 +25,11 @@ * requiredOptions: list * } * + * @phpstan-type UnmetModelRequirementsShape array{ + * capabilities: list, + * options: list + * } + * * @extends AbstractDataTransferObject */ class ModelRequirements extends AbstractDataTransferObject @@ -91,6 +96,23 @@ public function getRequiredOptions(): array * @return bool True if the model meets all requirements, false otherwise. */ public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\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 1.5.0 + * + * @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(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): array { // Create lookup maps for better performance (instead of nested foreach loops) $capabilitiesMap = []; @@ -101,25 +123,23 @@ public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata foreach ($metadata->getSupportedOptions() as $option) { $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]; - // Check if the required value is supported by this option - if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { - return \false; + $supportedOption = $optionsMap[$requiredOption->getName()->value] ?? null; + if ($supportedOption === null || !$supportedOption->isSupportedValue($requiredOption->getValue())) { + $unmetOptions[] = $requiredOption; } } - return \true; + return ['capabilities' => $unmetCapabilities, 'options' => $unmetOptions]; } /** * Creates ModelRequirements from prompt data and model configuration. diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php index 935459f44ec0a..38063ae24bf18 100644 --- a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php +++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php @@ -8,14 +8,15 @@ * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, - * including its name, description, and parameter schema. + * including its name, description, parameter schema, and optional annotations. * * @since 0.1.0 * * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array + * parameters?: array, + * annotations?: array * } * * @extends AbstractDataTransferObject @@ -25,6 +26,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; + public const KEY_ANNOTATIONS = 'annotations'; /** * @var string The name of the function. */ @@ -37,20 +39,27 @@ class FunctionDeclaration extends AbstractDataTransferObject * @var array|null The JSON schema for the function parameters. */ private ?array $parameters; + /** + * @var array Open-ended annotations which may be consumed by Providers. + */ + private array $annotations; /** * Constructor. * * @since 0.1.0 + * @since 1.5.0 Adds the optional $annotations parameter. * * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. + * @param array $annotations Optional annotations with JSON-serializable values. */ - public function __construct(string $name, string $description, ?array $parameters = null) + public function __construct(string $name, string $description, ?array $parameters = null, array $annotations = []) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; + $this->annotations = $annotations; } /** * Gets the function name. @@ -85,6 +94,17 @@ public function getParameters(): ?array { return $this->parameters; } + /** + * Gets the function annotations. + * + * @since 1.5.0 + * + * @return array The annotations, or an empty array if none were provided. + */ + public function getAnnotations(): array + { + return $this->annotations; + } /** * {@inheritDoc} * @@ -92,7 +112,7 @@ public function getParameters(): ?array */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]]; + return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true], self::KEY_ANNOTATIONS => ['type' => 'object', 'description' => 'Optional annotations interpreted by consumers.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]]; } /** * {@inheritDoc} @@ -107,6 +127,9 @@ public function toArray(): array if ($this->parameters !== null) { $data[self::KEY_PARAMETERS] = $this->parameters; } + if ($this->annotations !== []) { + $data[self::KEY_ANNOTATIONS] = $this->annotations; + } return $data; } /** @@ -117,6 +140,6 @@ public function toArray(): array public static function fromArray(array $array): self { static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_DESCRIPTION]); - return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null); + return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null, $array[self::KEY_ANNOTATIONS] ?? []); } } From b9ad27121bf44ddae910837286623682341c4417 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:44:57 +0000 Subject: [PATCH 10/15] AI: Require an explicit model in WP_AI_Client_Embedding_Builder. Follows the PHP AI Client 1.5.0 change that removes automatic model selection from `EmbeddingBuilder`. Updates the documented methods to drop `using_provider()` and `using_model_preference()` and add `using_provider_model()`, and documents that a model is required. Without a model, `is_supported()` returns false and generating methods return an `embedding_invalid_argument` error, through the existing `Throwable` handling in `WP_AI_Client_Builder`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- src/wp-includes/ai-client.php | 4 ++ .../class-wp-ai-client-embedding-builder.php | 13 ++-- .../ai-client/wpAiClientEmbeddingBuilder.php | 65 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index d8fd16c270af7..16c78e63297d6 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -72,6 +72,10 @@ function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { * complex types. Pass multiple arguments to embed multiple inputs at once, each * producing its own embedding vector. * + * A model must be specified on the returned builder via `using_model()` or + * `using_provider_model()`. Embedding vectors are only comparable to other + * vectors produced by the same model, so no model is selected automatically. + * * @since 7.2.0 * * @param string|MessagePart|File|array ...$input Optional. Initial input(s) to embed. diff --git a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php index 5500d8f836b8c..eb041801c2887 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php @@ -24,11 +24,17 @@ * adds WordPress-specific behavior including WP_Error handling instead of * exceptions and snake_case method naming. * + * A model must be specified via `using_model()` or `using_provider_model()`. + * Embedding vectors are only comparable to other vectors produced by the same + * model, so no model is selected automatically. + * * Only the generating methods will return a WP_Error, to not break the fluent * interface. As soon as any exception is caught in a chain of method calls, * the returned instance will be in an error state, and all subsequent method * calls will be no-ops that just return the same error state instance. Only - * when a generating method is called, the WP_Error will be returned. + * when a generating method is called, the WP_Error will be returned. The + * support check methods are the exception to the no-op behavior: they return + * false rather than the error state instance. * * @since 7.2.0 * @@ -39,11 +45,10 @@ * @method self with_input(...$input) Adds one or more inputs to embed. * @method self using_dimensions(int $dimensions) Sets the embedding dimensions. * @method self using_model(ModelInterface $model) Sets the model to use for generation. - * @method self using_model_preference(...$preferredModels) Sets preferred models to evaluate in order. + * @method self using_provider_model(string $providerIdOrClassName, string $modelId) Sets the model to use for generation, by provider and model identifier. * @method self using_model_config(ModelConfig $config) Sets the model configuration. - * @method self using_provider(string $providerIdOrClassName) Sets the provider to use for generation. * @method self using_request_options(RequestOptions $options) Sets the request options for HTTP transport. - * @method bool is_supported() Checks whether the current inputs and configuration are supported by an available model. + * @method bool is_supported() Checks whether the specified model supports the current inputs and configuration. * @method EmbeddingResult|WP_Error generate_embedding_result() Generates an embedding result from the configured inputs. * @method Embedding|WP_Error generate_embedding() Generates a single embedding from the configured input. * @method list|WP_Error generate_embeddings() Generates embeddings from the configured inputs. diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index e7bf9bbbb485d..05e4a4eda28da 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -44,6 +44,7 @@ public function set_up() { parent::set_up(); $this->registry = $this->createMock( ProviderRegistry::class ); + $this->registry->method( 'isProviderConfigured' )->willReturn( true ); } /** @@ -515,6 +516,70 @@ public function test_is_supported_with_capable_model() { $this->assertTrue( $builder->using_model( $model )->is_supported() ); } + /** + * Test that is_supported() returns false when no model was specified. + * + * Embeddings are only comparable to other embeddings from the same model, so + * the SDK never selects a model automatically. + * + * @ticket 64591 + */ + public function test_is_supported_returns_false_without_model() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that generating without a model returns a WP_Error. + * + * @ticket 64591 + */ + public function test_generate_embeddings_without_model_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $error = $builder->generate_embeddings(); + + $this->assertWPError( $error ); + $this->assertSame( 'embedding_invalid_argument', $error->get_error_code() ); + } + + /** + * Test that is_supported() returns false when the model's provider is not configured. + * + * @ticket 64591 + */ + public function test_is_supported_returns_false_when_provider_not_configured() { + $registry = $this->createMock( ProviderRegistry::class ); + $registry->method( 'isProviderConfigured' )->willReturn( false ); + + $model = $this->create_mock_embedding_model( $this->create_test_embedding_result( 1 ) ); + $builder = new WP_AI_Client_Embedding_Builder( $registry, 'Test input' ); + + $this->assertFalse( $builder->using_model( $model )->is_supported() ); + } + + /** + * Test that using_provider_model() retrieves the model from the registry. + * + * @ticket 64591 + */ + public function test_using_provider_model_generates_with_registry_model() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( 'mock', 'test-embedding-model' ) + ->willReturn( $model ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $actual_result = $builder->using_provider_model( 'mock', 'test-embedding-model' )->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + } + /** * Test that generating returns a WP_Error when the prevent embedding filter returns true. * From 32956e9d35c020236640c5fc3f8de69f7a72da4d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:05:17 +0000 Subject: [PATCH 11/15] AI: Accept a list of inputs in wp_ai_client_embedding(). `wp_ai_client_embedding( array( 'First', 'Second' ) )` previously treated the array as a single input and failed with an `embedding_invalid_argument` error. A single list argument is now unwrapped into individual inputs, so callers no longer need to spread an existing array. Passing multiple arguments still works as before. This is unambiguous: the only array that is a valid single input is a message part array shape, which is associative and never a list. The SDK's `EmbeddingBuilder` constructor relies on the same distinction. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- src/wp-includes/ai-client.php | 14 ++++- .../tests/ai-client/wpAiClientEmbedding.php | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 16c78e63297d6..8cc772767a098 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -69,8 +69,8 @@ function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { * a fluent builder that can be used to configure and generate embedding vectors. * * Each input can be provided as a simple string for basic text inputs, or as more - * complex types. Pass multiple arguments to embed multiple inputs at once, each - * producing its own embedding vector. + * complex types. Pass multiple arguments, or a single list of inputs, to embed + * multiple inputs at once, each producing its own embedding vector. * * A model must be specified on the returned builder via `using_model()` or * `using_provider_model()`. Embedding vectors are only comparable to other @@ -83,8 +83,18 @@ function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { * a MessagePart or File object for * structured content, or an array for * a message part array shape. + * Alternatively, a single list of any + * of these to embed multiple inputs. * @return WP_AI_Client_Embedding_Builder The embedding builder instance. */ function wp_ai_client_embedding( ...$input ): WP_AI_Client_Embedding_Builder { + /* + * A single list argument is a list of inputs. It cannot be a single input, since + * the only array input is a message part array shape, which is never a list. + */ + if ( 1 === count( $input ) && isset( $input[0] ) && is_array( $input[0] ) && array_is_list( $input[0] ) ) { + $input = $input[0]; + } + return new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), array() === $input ? null : $input ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php index b1182fdbe91b7..37bd7e836be5b 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php @@ -94,4 +94,67 @@ public function test_variadic_arguments_become_independent_inputs() { $this->assertCount( 3, $inputs ); $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); } + + /** + * Test that a single list argument becomes independent inputs. + * + * @ticket 64591 + */ + public function test_single_list_argument_becomes_independent_inputs() { + $builder = wp_ai_client_embedding( array( 'First', 'Second', 'Third' ) ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertSame( 'First', $inputs[0]->getText() ); + $this->assertSame( 'Second', $inputs[1]->getText() ); + $this->assertSame( 'Third', $inputs[2]->getText() ); + } + + /** + * Test that a single list argument can mix input types. + * + * @ticket 64591 + */ + public function test_single_list_argument_with_mixed_input_types() { + $part_shape = ( new MessagePart( 'Third' ) )->toArray(); + + $builder = wp_ai_client_embedding( array( 'First', new MessagePart( 'Second' ), $part_shape ) ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertSame( 'Third', $inputs[2]->getText() ); + } + + /** + * Test that a single message part array shape argument stays a single input. + * + * @ticket 64591 + */ + public function test_single_array_shape_argument_becomes_single_input() { + $builder = wp_ai_client_embedding( ( new MessagePart( 'Test input' ) )->toArray() ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 1, $inputs ); + $this->assertSame( 'Test input', $inputs[0]->getText() ); + } + + /** + * Test that a single empty array argument creates a builder without inputs or an error state. + * + * @ticket 64591 + */ + public function test_empty_array_argument_creates_builder_without_inputs() { + $builder = wp_ai_client_embedding( array() ); + + $this->assertSame( array(), $this->get_wrapped_builder_inputs( $builder ) ); + + $error_property = new ReflectionProperty( WP_AI_Client_Embedding_Builder::class, 'error' ); + if ( PHP_VERSION_ID < 80100 ) { + $error_property->setAccessible( true ); + } + $this->assertNull( $error_property->getValue( $builder ), 'The builder should not be in an error state' ); + } } From 5675b4c8cf3a2b93d18cb67bbb25013bb6106337 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:10:33 +0000 Subject: [PATCH 12/15] AI: Test the embedding lifecycle action hooks. Adds a test that `generate_embedding_result()` fires `wp_ai_client_before_generate_embedding` and `wp_ai_client_after_generate_embedding` in order, mirroring the prompt builder's lifecycle hook test, and lists both hooks in the `WP_AI_Client_Event_Dispatcher::dispatch()` documentation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../class-wp-ai-client-event-dispatcher.php | 4 ++ .../ai-client/wpAiClientEmbeddingBuilder.php | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php index 0bfaae0163330..6a53425b59bd9 100644 --- a/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php @@ -48,8 +48,12 @@ public function dispatch( object $event ): object { * * - wp_ai_client_before_generate_result * - wp_ai_client_after_generate_result + * - wp_ai_client_before_generate_embedding + * - wp_ai_client_after_generate_embedding * * @since 7.0.0 + * @since 7.2.0 Added the `wp_ai_client_before_generate_embedding` and + * `wp_ai_client_after_generate_embedding` action hooks. * * @param object $event The event object. */ diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index 05e4a4eda28da..3ca7765a8de78 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -11,6 +11,8 @@ use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException as AiClientInvalidArgumentException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; +use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent; +use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; @@ -665,6 +667,45 @@ public function test_generate_embedding_result() { $this->assertSame( $result, $actual_result ); } + /** + * Test that generate_embedding_result() fires the embedding lifecycle action hooks in order. + * + * @ticket 64591 + */ + public function test_generate_embedding_result_fires_lifecycle_action_hooks() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $hook_order = array(); + $events = array(); + + add_action( + 'wp_ai_client_before_generate_embedding', + static function ( $event ) use ( &$hook_order, &$events ) { + $hook_order[] = 'before'; + $events['before'] = $event; + } + ); + add_action( + 'wp_ai_client_after_generate_embedding', + static function ( $event ) use ( &$hook_order, &$events ) { + $hook_order[] = 'after'; + $events['after'] = $event; + } + ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + $this->assertSame( array( 'before', 'after' ), $hook_order ); + $this->assertInstanceOf( BeforeGenerateEmbeddingEvent::class, $events['before'] ); + $this->assertInstanceOf( AfterGenerateEmbeddingEvent::class, $events['after'] ); + $this->assertSame( $result, $events['after']->getResult() ); + } + /** * Test that generate_embedding() returns a single embedding. * From d457fe51b67bf3a06da2ce72768315a7f4ae5e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:21:41 +0000 Subject: [PATCH 13/15] Tests: Reference the embedding ticket in the new AI client tests. Tests added for the embedding builder, `wp_ai_client_embedding()`, and the `wp_ai_client_default_request_timeout` builder type parameter now reference #65638 instead of the original AI client ticket #64591. Regression tests for fixes ported from trunk keep their original ticket alongside it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../tests/ai-client/wpAiClientEmbedding.php | 18 ++--- .../ai-client/wpAiClientEmbeddingBuilder.php | 72 ++++++++++--------- .../ai-client/wpAiClientPromptBuilder.php | 2 +- 3 files changed, 48 insertions(+), 44 deletions(-) diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php index 37bd7e836be5b..41f885fd720ef 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php @@ -36,7 +36,7 @@ private function get_wrapped_builder_inputs( WP_AI_Client_Embedding_Builder $bui /** * Test that wp_ai_client_embedding() returns a WP_AI_Client_Embedding_Builder instance. * - * @ticket 64591 + * @ticket 65638 */ public function test_returns_embedding_builder_instance() { $builder = wp_ai_client_embedding(); @@ -47,7 +47,7 @@ public function test_returns_embedding_builder_instance() { /** * Test that successive calls return independent builder instances. * - * @ticket 64591 + * @ticket 65638 */ public function test_returns_independent_instances() { $builder1 = wp_ai_client_embedding( 'First' ); @@ -59,7 +59,7 @@ public function test_returns_independent_instances() { /** * Test that calling with no arguments creates a builder without inputs or an error state. * - * @ticket 64591 + * @ticket 65638 */ public function test_no_arguments_creates_builder_without_inputs() { $builder = wp_ai_client_embedding(); @@ -70,7 +70,7 @@ public function test_no_arguments_creates_builder_without_inputs() { /** * Test that a single argument becomes a single input. * - * @ticket 64591 + * @ticket 65638 */ public function test_single_argument_becomes_single_input() { $builder = wp_ai_client_embedding( 'Test input' ); @@ -84,7 +84,7 @@ public function test_single_argument_becomes_single_input() { /** * Test that multiple variadic arguments each become an independent input. * - * @ticket 64591 + * @ticket 65638 */ public function test_variadic_arguments_become_independent_inputs() { $builder = wp_ai_client_embedding( 'First', 'Second', 'Third' ); @@ -98,7 +98,7 @@ public function test_variadic_arguments_become_independent_inputs() { /** * Test that a single list argument becomes independent inputs. * - * @ticket 64591 + * @ticket 65638 */ public function test_single_list_argument_becomes_independent_inputs() { $builder = wp_ai_client_embedding( array( 'First', 'Second', 'Third' ) ); @@ -114,7 +114,7 @@ public function test_single_list_argument_becomes_independent_inputs() { /** * Test that a single list argument can mix input types. * - * @ticket 64591 + * @ticket 65638 */ public function test_single_list_argument_with_mixed_input_types() { $part_shape = ( new MessagePart( 'Third' ) )->toArray(); @@ -130,7 +130,7 @@ public function test_single_list_argument_with_mixed_input_types() { /** * Test that a single message part array shape argument stays a single input. * - * @ticket 64591 + * @ticket 65638 */ public function test_single_array_shape_argument_becomes_single_input() { $builder = wp_ai_client_embedding( ( new MessagePart( 'Test input' ) )->toArray() ); @@ -144,7 +144,7 @@ public function test_single_array_shape_argument_becomes_single_input() { /** * Test that a single empty array argument creates a builder without inputs or an error state. * - * @ticket 64591 + * @ticket 65638 */ public function test_empty_array_argument_creates_builder_without_inputs() { $builder = wp_ai_client_embedding( array() ); diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index 3ca7765a8de78..599bf3d756178 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -223,7 +223,7 @@ public function generateEmbeddingResult( array $inputs ): EmbeddingResult { // p /** * Test that WP_AI_Client_Embedding_Builder can be instantiated. * - * @ticket 64591 + * @ticket 65638 */ public function test_instantiation() { $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); @@ -234,7 +234,7 @@ public function test_instantiation() { /** * Test that WP_AI_Client_Embedding_Builder can be instantiated with initial input. * - * @ticket 64591 + * @ticket 65638 */ public function test_instantiation_with_input() { $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); @@ -245,7 +245,7 @@ public function test_instantiation_with_input() { /** * Test that the constructor parses a single string input into one message part. * - * @ticket 64591 + * @ticket 65638 */ public function test_constructor_with_string_input() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); @@ -259,7 +259,7 @@ public function test_constructor_with_string_input() { /** * Test that the constructor parses a list of inputs into one message part each. * - * @ticket 64591 + * @ticket 65638 */ public function test_constructor_with_input_list() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); @@ -273,7 +273,7 @@ public function test_constructor_with_input_list() { /** * Test that the constructor sets the default request timeout. * - * @ticket 64591 + * @ticket 65638 */ public function test_constructor_sets_default_request_timeout() { $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); @@ -288,7 +288,7 @@ public function test_constructor_sets_default_request_timeout() { /** * Test that the request timeout filter receives the embedding builder type. * - * @ticket 64591 + * @ticket 65638 */ public function test_request_timeout_filter_receives_builder_type() { $received_builder_type = null; @@ -311,7 +311,7 @@ static function ( $default_timeout, $builder_type ) use ( &$received_builder_typ /** * Test that the constructor allows overriding the default request timeout. * - * @ticket 64591 + * @ticket 65638 */ public function test_constructor_allows_overriding_request_timeout() { add_filter( @@ -332,7 +332,7 @@ static function () { /** * Test that the constructor rejects an invalid request timeout, reporting the concrete class name. * - * @ticket 64591 + * @ticket 65638 * * @expectedIncorrectUsage WP_AI_Client_Embedding_Builder::__construct */ @@ -350,7 +350,7 @@ public function test_constructor_disallows_overriding_with_invalid_request_timeo /** * Test that fluent methods return the wrapper instance for method chaining. * - * @ticket 64591 + * @ticket 65638 */ public function test_method_chaining_returns_wrapper() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -365,7 +365,7 @@ public function test_method_chaining_returns_wrapper() { /** * Test that with_input() appends inputs to the wrapped builder. * - * @ticket 64591 + * @ticket 65638 */ public function test_with_input_appends_inputs() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'First' ); @@ -380,7 +380,7 @@ public function test_with_input_appends_inputs() { /** * Test that using_dimensions() sets the dimensions on the model configuration. * - * @ticket 64591 + * @ticket 65638 */ public function test_using_dimensions_sets_model_config() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -395,7 +395,7 @@ public function test_using_dimensions_sets_model_config() { /** * Test that the wrapper passes the AiClient event dispatcher to the wrapped builder. * - * @ticket 64591 + * @ticket 65638 */ public function test_passes_ai_client_event_dispatcher_to_wrapped_builder() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -408,7 +408,7 @@ public function test_passes_ai_client_event_dispatcher_to_wrapped_builder() { /** * Test that calling a nonexistent method puts the builder in an error state. * - * @ticket 64591 + * @ticket 65638 */ public function test_invalid_method_returns_wp_error() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -423,7 +423,7 @@ public function test_invalid_method_returns_wp_error() { /** * Test that once in error state, subsequent fluent calls return the same instance. * - * @ticket 64591 + * @ticket 65638 */ public function test_error_state_fluent_calls_return_same_instance() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -441,7 +441,7 @@ public function test_error_state_fluent_calls_return_same_instance() { /** * Test that is_supported() returns false when in error state. * - * @ticket 64591 + * @ticket 65638 */ public function test_support_check_method_returns_false_in_error_state() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -455,7 +455,7 @@ public function test_support_check_method_returns_false_in_error_state() { /** * Test that an invalid constructor input puts the builder in an error state. * - * @ticket 64591 + * @ticket 65638 */ public function test_constructor_with_invalid_input_enters_error_state() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, '' ); @@ -469,7 +469,7 @@ public function test_constructor_with_invalid_input_enters_error_state() { /** * Test that generating without any inputs returns a WP_Error. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings_without_input_returns_wp_error() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); @@ -483,7 +483,7 @@ public function test_generate_embeddings_without_input_returns_wp_error() { /** * Test that is_supported() returns false when AI is not supported. * - * @ticket 64591 + * @ticket 65638 */ public function test_is_supported_returns_false_when_ai_not_supported() { add_filter( 'wp_supports_ai', '__return_false' ); @@ -496,7 +496,7 @@ public function test_is_supported_returns_false_when_ai_not_supported() { /** * Test that is_supported() returns false when the prevent embedding filter returns true. * - * @ticket 64591 + * @ticket 65638 */ public function test_is_supported_returns_false_when_filter_prevents_embedding() { add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); @@ -509,7 +509,7 @@ public function test_is_supported_returns_false_when_filter_prevents_embedding() /** * Test that is_supported() returns true for a model with the embedding generation capability. * - * @ticket 64591 + * @ticket 65638 */ public function test_is_supported_with_capable_model() { $model = $this->create_mock_embedding_model( $this->create_test_embedding_result( 1 ) ); @@ -524,7 +524,7 @@ public function test_is_supported_with_capable_model() { * Embeddings are only comparable to other embeddings from the same model, so * the SDK never selects a model automatically. * - * @ticket 64591 + * @ticket 65638 */ public function test_is_supported_returns_false_without_model() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); @@ -535,7 +535,7 @@ public function test_is_supported_returns_false_without_model() { /** * Test that generating without a model returns a WP_Error. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings_without_model_returns_wp_error() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); @@ -549,7 +549,7 @@ public function test_generate_embeddings_without_model_returns_wp_error() { /** * Test that is_supported() returns false when the model's provider is not configured. * - * @ticket 64591 + * @ticket 65638 */ public function test_is_supported_returns_false_when_provider_not_configured() { $registry = $this->createMock( ProviderRegistry::class ); @@ -564,7 +564,7 @@ public function test_is_supported_returns_false_when_provider_not_configured() { /** * Test that using_provider_model() retrieves the model from the registry. * - * @ticket 64591 + * @ticket 65638 */ public function test_using_provider_model_generates_with_registry_model() { $result = $this->create_test_embedding_result( 1 ); @@ -585,7 +585,7 @@ public function test_using_provider_model_generates_with_registry_model() { /** * Test that generating returns a WP_Error when the prevent embedding filter returns true. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings_returns_wp_error_when_filter_prevents_embedding() { add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); @@ -603,7 +603,7 @@ public function test_generate_embeddings_returns_wp_error_when_filter_prevents_e /** * Test that generating returns a WP_Error when AI is not supported. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings_returns_wp_error_when_ai_not_supported() { add_filter( 'wp_supports_ai', '__return_false' ); @@ -620,7 +620,7 @@ public function test_generate_embeddings_returns_wp_error_when_ai_not_supported( /** * Test that the prevent embedding filter receives a clone of the builder instance. * - * @ticket 64591 + * @ticket 65638 */ public function test_prevent_embedding_filter_receives_cloned_builder_instance() { $captured_builder = null; @@ -653,7 +653,7 @@ static function ( $prevent, $builder ) use ( &$captured_builder ) { /** * Test that generate_embedding_result() returns the result from the model. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embedding_result() { $result = $this->create_test_embedding_result( 1 ); @@ -670,7 +670,7 @@ public function test_generate_embedding_result() { /** * Test that generate_embedding_result() fires the embedding lifecycle action hooks in order. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embedding_result_fires_lifecycle_action_hooks() { $result = $this->create_test_embedding_result( 1 ); @@ -709,7 +709,7 @@ static function ( $event ) use ( &$hook_order, &$events ) { /** * Test that generate_embedding() returns a single embedding. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embedding() { $result = $this->create_test_embedding_result( 1 ); @@ -727,7 +727,7 @@ public function test_generate_embedding() { /** * Test that generate_embeddings() returns one embedding per input. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings() { $result = $this->create_test_embedding_result( 2 ); @@ -746,7 +746,7 @@ public function test_generate_embeddings() { /** * Test that generate_embedding() returns a WP_Error when multiple inputs are configured. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embedding_returns_wp_error_with_multiple_inputs() { $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); @@ -760,7 +760,7 @@ public function test_generate_embedding_returns_wp_error_with_multiple_inputs() /** * Test that a mismatched embedding count from the model results in a WP_Error. * - * @ticket 64591 + * @ticket 65638 */ public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { $result = $this->create_test_embedding_result( 1 ); @@ -778,7 +778,7 @@ public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { /** * Test that throwable_to_wp_error() maps throwables to embedding-prefixed error codes. * - * @ticket 64591 + * @ticket 65638 * * @dataProvider data_throwable_to_wp_error_mapping * @@ -821,6 +821,7 @@ public static function data_throwable_to_wp_error_mapping(): array { * `usingDimensions()` expects an int; an array can never be coerced and throws a * TypeError, which extends Error rather than Exception. * + * @ticket 65638 * @ticket 65505 */ public function test_call_catches_type_error_from_invalid_argument_type() { @@ -839,6 +840,7 @@ public function test_call_catches_type_error_from_invalid_argument_type() { /** * Test that is_supported() returns false when the wrapped builder throws. * + * @ticket 65638 * @ticket 65781 */ public function test_is_supported_returns_false_when_builder_throws() { @@ -863,6 +865,7 @@ public function test_is_supported_returns_false_when_builder_throws() { /** * Test that a clone does not share the wrapped builder with the original. * + * @ticket 65638 * @ticket 65782 */ public function test_clone_does_not_share_the_wrapped_builder() { @@ -877,6 +880,7 @@ public function test_clone_does_not_share_the_wrapped_builder() { /** * Test that the clone passed to the prevent embedding filter cannot change the inputs. * + * @ticket 65638 * @ticket 65782 */ public function test_prevent_embedding_filter_cannot_mutate_the_original_inputs() { diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 2129dfe641db5..18947a7b77668 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -206,7 +206,7 @@ public function test_constructor_sets_default_request_timeout() { /** * Test that the request timeout filter receives the builder type. * - * @ticket 64591 + * @ticket 65638 */ public function test_request_timeout_filter_receives_builder_type() { $received_builder_type = null; From f83123e42f4cd6fa8545f3145333c56945128a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:25:14 +0000 Subject: [PATCH 14/15] AI: Keep WP_AI_Client_Builder internals private. `$builder`, `$error` and `throwable_to_wp_error()` were private on `WP_AI_Client_Prompt_Builder` in 7.0 and neither child class uses them, so they are private on the base class too rather than becoming new protected API. `get_builder_callable()` stays protected, as it was in 7.0. The base class is also marked `@internal` and `@access private`, like the AI client adapters, so plugins don't extend it and abstract methods can be added later without breaking subclasses. Tests now reflect on `WP_AI_Client_Builder` to reach these members. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../ai-client/class-wp-ai-client-builder.php | 9 ++++++--- .../tests/ai-client/wpAiClientEmbedding.php | 4 ++-- .../tests/ai-client/wpAiClientEmbeddingBuilder.php | 8 ++++---- .../tests/ai-client/wpAiClientPromptBuilder.php | 14 +++++++------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php index a8a95e448eee2..db36442bb9f31 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -32,6 +32,9 @@ * false rather than the error state instance. * * @since 7.2.0 + * @internal Intended only as the shared base for the AI client builders in core. + * It may gain abstract methods in the future, so it should not be extended. + * @access private */ abstract class WP_AI_Client_Builder { @@ -42,7 +45,7 @@ abstract class WP_AI_Client_Builder { * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * @var object */ - protected object $builder; + private object $builder; /** * WordPress error instance, if any error occurred during method calls. @@ -51,7 +54,7 @@ abstract class WP_AI_Client_Builder { * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. * @var WP_Error|null */ - protected ?WP_Error $error = null; + private ?WP_Error $error = null; /** * Constructor. @@ -288,7 +291,7 @@ public function __call( string $name, array $arguments ) { * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error object. */ - protected function throwable_to_wp_error( Throwable $throwable ): WP_Error { + private function throwable_to_wp_error( Throwable $throwable ): WP_Error { $prefix = $this->get_error_code_prefix(); if ( $throwable instanceof NetworkException ) { diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php index 41f885fd720ef..65c3b1f999c5b 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php @@ -17,7 +17,7 @@ class Tests_AI_Client_Embedding extends WP_UnitTestCase { * @return array The wrapped builder's inputs. */ private function get_wrapped_builder_inputs( WP_AI_Client_Embedding_Builder $builder ): array { - $reflection_class = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); if ( PHP_VERSION_ID < 80100 ) { $builder_property->setAccessible( true ); @@ -151,7 +151,7 @@ public function test_empty_array_argument_creates_builder_without_inputs() { $this->assertSame( array(), $this->get_wrapped_builder_inputs( $builder ) ); - $error_property = new ReflectionProperty( WP_AI_Client_Embedding_Builder::class, 'error' ); + $error_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'error' ); if ( PHP_VERSION_ID < 80100 ) { $error_property->setAccessible( true ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index 599bf3d756178..42f67fddb29e8 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -74,7 +74,7 @@ private static function set_accessible( $reflector ) { * @return mixed The property value. */ private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Builder $builder, string $property ) { - $reflection_class = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); self::set_accessible( $builder_property ); $wrapped_builder = $builder_property->getValue( $builder ); @@ -95,14 +95,14 @@ private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Buil } /** - * Invokes the protected throwable_to_wp_error() method on a builder. + * Invokes the private throwable_to_wp_error() method on a builder. * * @param WP_AI_Client_Embedding_Builder $builder The builder to invoke the method on. * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error. */ private function invoke_throwable_to_wp_error( WP_AI_Client_Embedding_Builder $builder, Throwable $throwable ): WP_Error { - $reflection = new ReflectionClass( WP_AI_Client_Embedding_Builder::class ); + $reflection = new ReflectionClass( WP_AI_Client_Builder::class ); $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method ); @@ -850,7 +850,7 @@ public function test_is_supported_returns_false_when_builder_throws() { $wrapped_builder->method( 'isSupported' ) ->willThrowException( new RuntimeException( 'Thrown by the wrapped builder.' ) ); - $builder_property = new ReflectionProperty( WP_AI_Client_Embedding_Builder::class, 'builder' ); + $builder_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $builder_property ); $builder_property->setValue( $builder, $wrapped_builder ); diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 18947a7b77668..bb1fe9c5baacd 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -105,7 +105,7 @@ private static function set_accessible( $reflector ) { * @return mixed The property value. */ private function get_wrapped_prompt_builder_property_value( WP_AI_Client_Prompt_Builder $builder, string $property ) { - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); self::set_accessible( $builder_property ); $wrapped_builder = $builder_property->getValue( $builder ); @@ -1420,7 +1420,7 @@ public function test_validate_messages_non_user_last_returns_wp_error() { ); // Manually add a model message as the last message. - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); self::set_accessible( $builder_property ); $wrapped_builder = $builder_property->getValue( $builder ); @@ -2647,7 +2647,7 @@ static function ( $prevent, $builder ) use ( &$captured_builder ) { * @return string[] The text of each message part, in order. */ private function get_prompt_parts( WP_AI_Client_Prompt_Builder $builder ): array { - $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $wrapped = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $wrapped ); $inner = $wrapped->getValue( $builder ); @@ -2673,7 +2673,7 @@ public function test_clone_does_not_share_the_wrapped_builder() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Original prompt' ); $clone = clone $builder; - $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $wrapped = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $wrapped ); $this->assertNotSame( @@ -2762,7 +2762,7 @@ public function test_support_check_methods_return_false_when_builder_throws( $me $wrapped_builder->method( $wrapped_method ) ->willThrowException( new RuntimeException( 'Thrown by the wrapped builder.' ) ); - $builder_property = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $builder_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $builder_property ); $builder_property->setValue( $prompt_builder, $wrapped_builder ); @@ -2938,14 +2938,14 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat } /** - * Invokes the protected throwable_to_wp_error method via reflection. + * Invokes the private throwable_to_wp_error method via reflection. * * @param WP_AI_Client_Prompt_Builder $builder The builder instance. * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error. */ private function invoke_throwable_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $throwable ): WP_Error { - $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection = new ReflectionClass( WP_AI_Client_Builder::class ); $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method ); From 1824a0c468bbdac1360cf5279ffc7fa7c33b17de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:30:18 +0000 Subject: [PATCH 15/15] Tests: Prefer behavior over private members in embedding builder tests. The throwable mapping test no longer invokes the private `throwable_to_wp_error()` through reflection. The mock model now throws each throwable from `generateEmbeddingResult()`, and the test asserts on the `WP_Error` that `generate_embedding_result()` returns. Removes two tests that are now redundant: * `test_is_supported_returns_false_when_builder_throws` swapped the private wrapped builder for a mock. `test_is_supported_returns_false_without_model` covers the same behavior through the real SDK path. * `test_passes_ai_client_event_dispatcher_to_wrapped_builder` is covered by `test_generate_embedding_result_fires_lifecycle_action_hooks`, which fails if the dispatcher is not passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Av4uVj7v96r2bUKX7kMd8z --- .../ai-client/wpAiClientEmbeddingBuilder.php | 87 +++++-------------- 1 file changed, 20 insertions(+), 67 deletions(-) diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php index 42f67fddb29e8..8cd40d36620ef 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -8,7 +8,6 @@ */ use WordPress\AiClient\AiClient; -use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException as AiClientInvalidArgumentException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent; @@ -94,21 +93,6 @@ private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Buil return $the_property->getValue( $wrapped_builder ); } - /** - * Invokes the private throwable_to_wp_error() method on a builder. - * - * @param WP_AI_Client_Embedding_Builder $builder The builder to invoke the method on. - * @param Throwable $throwable The throwable to convert. - * @return WP_Error The resulting WP_Error. - */ - private function invoke_throwable_to_wp_error( WP_AI_Client_Embedding_Builder $builder, Throwable $throwable ): WP_Error { - $reflection = new ReflectionClass( WP_AI_Client_Builder::class ); - $method = $reflection->getMethod( 'throwable_to_wp_error' ); - self::set_accessible( $method ); - - return $method->invoke( $builder, $throwable ); - } - /** * Returns the inputs held by the wrapped embedding builder. * @@ -164,12 +148,13 @@ private function create_test_embedding_result( int $count, int $dimensions = 3 ) /** * Creates a mock embedding generation model using an anonymous class. * - * @param EmbeddingResult $result The result to return from generation. - * @param ModelMetadata|null $metadata Optional metadata. + * @param EmbeddingResult|Throwable $result The result to return from generation, + * or a throwable to throw instead. + * @param ModelMetadata|null $metadata Optional metadata. * @return ModelInterface&EmbeddingGenerationModelInterface The mock model. */ private function create_mock_embedding_model( - EmbeddingResult $result, + $result, ?ModelMetadata $metadata = null ): ModelInterface { $metadata = $metadata ?? $this->create_test_embedding_model_metadata(); @@ -184,13 +169,14 @@ private function create_mock_embedding_model( private ModelMetadata $metadata; private ProviderMetadata $provider_metadata; - private EmbeddingResult $result; + /** @var EmbeddingResult|Throwable */ + private $result; private ModelConfig $config; public function __construct( ModelMetadata $metadata, ProviderMetadata $provider_metadata, - EmbeddingResult $result + $result ) { $this->metadata = $metadata; $this->provider_metadata = $provider_metadata; @@ -215,6 +201,9 @@ public function getConfig(): ModelConfig { } public function generateEmbeddingResult( array $inputs ): EmbeddingResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + if ( $this->result instanceof Throwable ) { + throw $this->result; + } return $this->result; } }; @@ -392,19 +381,6 @@ public function test_using_dimensions_sets_model_config() { $this->assertSame( 256, $config->getDimensions() ); } - /** - * Test that the wrapper passes the AiClient event dispatcher to the wrapped builder. - * - * @ticket 65638 - */ - public function test_passes_ai_client_event_dispatcher_to_wrapped_builder() { - $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); - - $dispatcher = $this->get_wrapped_builder_property_value( $builder, 'eventDispatcher' ); - - $this->assertSame( AiClient::getEventDispatcher(), $dispatcher ); - } - /** * Test that calling a nonexistent method puts the builder in an error state. * @@ -776,19 +752,21 @@ public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { } /** - * Test that throwable_to_wp_error() maps throwables to embedding-prefixed error codes. + * Test that a throwable from the model is returned as a WP_Error with an embedding-prefixed code. * * @ticket 65638 * - * @dataProvider data_throwable_to_wp_error_mapping + * @dataProvider data_generate_embedding_result_maps_throwable_to_wp_error * - * @param Throwable $throwable The throwable to convert. + * @param Throwable $throwable The throwable thrown by the model. * @param string $expected_code The expected WP_Error code. * @param int $expected_status The expected HTTP status in the error data. */ - public function test_throwable_to_wp_error_mapping( Throwable $throwable, string $expected_code, int $expected_status ) { - $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_throwable_to_wp_error( $builder, $throwable ); + public function test_generate_embedding_result_maps_throwable_to_wp_error( Throwable $throwable, string $expected_code, int $expected_status ) { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $error = $builder->using_model( $this->create_mock_embedding_model( $throwable ) )->generate_embedding_result(); + + $this->assertWPError( $error ); $this->assertSame( $expected_code, $error->get_error_code() ); $this->assertSame( $throwable->getMessage(), $error->get_error_message() ); @@ -797,11 +775,11 @@ public function test_throwable_to_wp_error_mapping( Throwable $throwable, string } /** - * Data provider for {@see self::test_throwable_to_wp_error_mapping()}. + * Data provider for {@see self::test_generate_embedding_result_maps_throwable_to_wp_error()}. * * @return array */ - public static function data_throwable_to_wp_error_mapping(): array { + public static function data_generate_embedding_result_maps_throwable_to_wp_error(): array { return array( 'NetworkException' => array( new NetworkException( 'network error' ), 'embedding_network_error', 503 ), 'ClientException with code' => array( new ClientException( 'unauthorized', 401 ), 'embedding_client_error', 401 ), @@ -837,31 +815,6 @@ public function test_call_catches_type_error_from_invalid_argument_type() { $this->assertSame( 'TypeError', $error->get_error_data()['exception_class'] ); } - /** - * Test that is_supported() returns false when the wrapped builder throws. - * - * @ticket 65638 - * @ticket 65781 - */ - public function test_is_supported_returns_false_when_builder_throws() { - $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); - - $wrapped_builder = $this->createMock( EmbeddingBuilder::class ); - $wrapped_builder->method( 'isSupported' ) - ->willThrowException( new RuntimeException( 'Thrown by the wrapped builder.' ) ); - - $builder_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); - self::set_accessible( $builder_property ); - $builder_property->setValue( $builder, $wrapped_builder ); - - $this->assertFalse( $builder->is_supported(), 'is_supported should return false when the wrapped builder throws' ); - - // The error is still recorded, so a generating method returns it. - $error = $builder->generate_embeddings(); - $this->assertWPError( $error ); - $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); - } - /** * Test that a clone does not share the wrapped builder with the original. *