diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php
index b38c7b721416d..8cc772767a098 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,40 @@ 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, 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
+ * 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.
+ * A string for simple text inputs,
+ * 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/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/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..db36442bb9f31
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php
@@ -0,0 +1,408 @@
+builder = $this->create_sdk_builder( $registry, $input );
+ } catch ( Throwable $e ) {
+ $this->builder = $this->create_sdk_builder( $registry, null );
+ $this->error = $this->throwable_to_wp_error( $e );
+ }
+
+ $default_timeout = 30.0;
+
+ /**
+ * Filters the default request timeout in seconds for AI Client HTTP requests.
+ *
+ * @since 7.0.0
+ * @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,
+ * 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, get_class( $this ) );
+ 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,
+ )
+ )
+ );
+ }
+
+ /**
+ * 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.
+ *
+ * @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.
+ * @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.2.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.2.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.2.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.2.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.2.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, or false when a support check method is called.
+ *
+ * @since 7.0.0
+ * @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.
+ * @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 ( 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 a throwable into a WP_Error with a structured error code and message.
+ *
+ * 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.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.
+ */
+ private function throwable_to_wp_error( Throwable $throwable ): WP_Error {
+ $prefix = $this->get_error_code_prefix();
+
+ if ( $throwable instanceof NetworkException ) {
+ $error_code = $prefix . '_network_error';
+ $status_code = 503;
+ } 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 = $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 = $throwable->getCode() ? $throwable->getCode() : 500;
+ } elseif ( $throwable instanceof TokenLimitReachedException ) {
+ $error_code = $prefix . '_token_limit_reached';
+ $status_code = 400;
+ } elseif ( $throwable 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,
+ $throwable->getMessage(),
+ array(
+ 'status' => $status_code,
+ 'exception_class' => get_class( $throwable ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a method name is a support check method (is_supported*).
+ *
+ * @since 7.0.0
+ * @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.
+ * @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.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.
+ * @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.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.
+ *
+ * @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.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.
+ */
+ 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
new file mode 100644
index 0000000000000..eb041801c2887
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php
@@ -0,0 +1,165 @@
+|WP_Error generate_embeddings() Generates embeddings from the configured inputs.
+ */
+class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder {
+
+ /**
+ * List of methods that generate embeddings from the inputs.
+ *
+ * Structured as a map for faster lookups.
+ *
+ * @since 7.2.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.2.0
+ * @var array
+ */
+ private static array $support_check_methods = array(
+ 'is_supported' => true,
+ );
+
+ /**
+ * Creates the wrapped embedding builder instance from the PHP AI Client SDK.
+ *
+ * @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.
+ * 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.
+ * @return EmbeddingBuilder The SDK embedding builder instance.
+ */
+ protected function create_sdk_builder( ProviderRegistry $registry, $input ): object {
+ return new EmbeddingBuilder( $registry, $input, AiClient::getEventDispatcher() );
+ }
+
+ /**
+ * Retrieves the prefix used for WP_Error codes created by this builder.
+ *
+ * @since 7.2.0
+ *
+ * @return string The error code prefix.
+ */
+ protected function get_error_code_prefix(): string {
+ return 'embedding';
+ }
+
+ /**
+ * Retrieves the error message to use when the embedding generation is prevented by a filter.
+ *
+ * @since 7.2.0
+ *
+ * @return string The translated error message.
+ */
+ protected function get_prevented_error_message(): string {
+ return __( 'Embedding generation was prevented by a filter.' );
+ }
+
+ /**
+ * Checks whether the embedding generation is prevented by the `wp_ai_client_prevent_embedding` filter.
+ *
+ * @since 7.2.0
+ *
+ * @return bool Whether the embedding generation is prevented.
+ */
+ protected function is_prevented_by_filter(): bool {
+ /**
+ * Filters whether to prevent the embedding generation from being executed.
+ *
+ * @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).
+ */
+ return (bool) apply_filters( 'wp_ai_client_prevent_embedding', false, clone $this );
+ }
+
+ /**
+ * Retrieves the methods that generate embeddings from the inputs.
+ *
+ * @since 7.2.0
+ *
+ * @return array The generating methods map.
+ */
+ protected function get_generating_methods(): array {
+ return self::$generating_methods;
+ }
+
+ /**
+ * Retrieves the methods that check whether the embedding generation is supported.
+ *
+ * @since 7.2.0
+ *
+ * @return array The support check methods map.
+ */
+ 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 b2205e32222c7..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
@@ -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;
@@ -48,6 +43,9 @@
* false rather than the error state instance.
*
* @since 7.0.0
+ * @since 7.2.0 Now extends the `WP_AI_Client_Builder` base class.
+ *
+ * @see WP_AI_Client_Builder
*
* @phpstan-import-type Prompt from PromptBuilder
*
@@ -106,23 +104,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.
@@ -170,78 +152,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 ( Throwable $e ) {
- $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() );
- $this->error = $this->throwable_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,
- )
- )
- );
- }
-
- /**
- * Clones the wrapped prompt 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;
- }
- }
-
/**
* Registers WordPress abilities as function declarations for the AI model.
*
@@ -297,213 +207,84 @@ public function using_abilities( ...$abilities ): self {
}
/**
- * 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, or false when a support check method is called.
+ * Creates the wrapped prompt builder instance from the PHP AI Client SDK.
*
- * @since 7.0.0
+ * @since 7.2.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 ( Throwable $e ) {
- $this->error = $this->throwable_to_wp_error( $e );
-
- if ( self::is_generating_method( $name ) ) {
- return $this->error;
- }
- if ( self::is_support_check_method( $name ) ) {
- return false;
- }
- return $this;
- }
+ protected function create_sdk_builder( ProviderRegistry $registry, $prompt ): object {
+ return new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() );
}
/**
- * Converts a throwable into a WP_Error with a structured error code and message.
- *
- * 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.
+ * Retrieves the prefix used for WP_Error codes created by this builder.
*
- * @since 7.0.0
+ * @since 7.2.0
*
- * @param Throwable $throwable The throwable to convert.
- * @return WP_Error The resulting WP_Error object.
+ * @return string The error code prefix.
*/
- private function throwable_to_wp_error( Throwable $throwable ): WP_Error {
- if ( $throwable instanceof NetworkException ) {
- $error_code = 'prompt_network_error';
- $status_code = 503;
- } elseif ( $throwable instanceof ClientException ) {
- // `ClientException` uses HTTP status codes as exception codes, so we can rely on them.
- $error_code = 'prompt_client_error';
- $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 = 'prompt_upstream_server_error';
- $status_code = $throwable->getCode() ? $throwable->getCode() : 500;
- } elseif ( $throwable instanceof TokenLimitReachedException ) {
- $error_code = 'prompt_token_limit_reached';
- $status_code = 400;
- } elseif ( $throwable 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,
- $throwable->getMessage(),
- array(
- 'status' => $status_code,
- 'exception_class' => get_class( $throwable ),
- )
- );
+ 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.2.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.2.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.2.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.2.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/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php
index 43be86aa0be4d..4ee6ec969f34d 100644
--- a/src/wp-includes/php-ai-client/src/AiClient.php
+++ b/src/wp-includes/php-ai-client/src/AiClient.php
@@ -5,14 +5,18 @@
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;
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;
+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 +80,8 @@
* @since 0.1.0
*
* @phpstan-import-type Prompt from PromptBuilder
+ * @phpstan-import-type EmbeddingInput from EmbeddingBuilder
+ * @phpstan-import-type ProviderModelTuple from ModelResolver
*
* phpcs:ignore Generic.Files.LineLength.TooLong
*/
@@ -84,7 +90,7 @@ class AiClient
/**
* @var string The version of the AI Client.
*/
- public const VERSION = '1.3.1';
+ public const VERSION = '1.5.0';
/**
* @var ProviderRegistry|null The default provider registry instance.
*/
@@ -211,6 +217,27 @@ 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.
+ *
+ * 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.
+ * @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 +361,63 @@ 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.
+ *
+ * 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|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 or model format is invalid, or if the model
+ * cannot fulfill the request.
+ */
+ public static function generateEmbeddingResult($input, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): EmbeddingResult
+ {
+ 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|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, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): Embedding
+ {
+ 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|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, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): array
+ {
+ return self::getConfiguredEmbeddingBuilder($inputs, $model, $modelConfig, $registry)->generateEmbeddings();
+ }
/**
* Creates a new message builder for fluent API usage.
*
@@ -375,7 +459,49 @@ 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);
+ }
+ /**
+ * 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
+ *
+ * @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..a23a740ea277a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php
@@ -0,0 +1,534 @@
+ 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.
+ */
+ private ?EventDispatcherInterface $eventDispatcher;
+ /**
+ * Constructor.
+ *
+ * @since 1.4.0
+ *
+ * @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->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, model configuration, and request options. Service objects (registry, model,
+ * 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;
+ 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.
+ *
+ * @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 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.
+ *
+ * @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 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 the specified model supports embedding generation for the current
+ * inputs and configuration.
+ * @throws InvalidArgumentException If no model was specified.
+ */
+ public function isSupported(): bool
+ {
+ 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.
+ *
+ * @since 1.4.0
+ *
+ * @return EmbeddingResult The generated embedding result.
+ * @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
+ {
+ if ($this->inputs === []) {
+ throw new InvalidArgumentException('Cannot generate embeddings from empty input. Add content using withInput().');
+ }
+ $capability = CapabilityEnum::embeddingGeneration();
+ $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.
+ 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, no model was specified, the
+ * specified model cannot fulfill the request, 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, 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.
+ *
+ * @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/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
new file mode 100644
index 0000000000000..a04abcb23c9fa
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php
@@ -0,0 +1,92 @@
+modelResolver->setModel($model);
+ // Merge model's config with builder's config, with builder's config taking precedence
+ $this->mergeModelConfig($model->getConfig());
+ return $this;
+ }
+ /**
+ * Sets preferred models to evaluate in order.
+ *
+ * @since 0.2.0
+ *
+ * @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.
+ * @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 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/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
new file mode 100644
index 0000000000000..975a2bb3d7694
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php
@@ -0,0 +1,378 @@
+ 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|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.
+ * @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..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
@@ -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;
@@ -24,6 +25,11 @@
* requiredOptions: list
* }
*
+ * @phpstan-type UnmetModelRequirementsShape array{
+ * capabilities: list,
+ * options: list
+ * }
+ *
* @extends AbstractDataTransferObject
*/
class ModelRequirements extends AbstractDataTransferObject
@@ -90,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 = [];
@@ -100,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.
@@ -143,24 +164,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 +189,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 +325,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] ?? []);
+ }
+}
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] ?? []);
}
}
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 8c9ea4a86be37..07756eb0480c2 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -295,7 +295,9 @@
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';
require ABSPATH . WPINC . '/class-wp-connector-registry.php';
require ABSPATH . WPINC . '/connectors.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..65c3b1f999c5b
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php
@@ -0,0 +1,160 @@
+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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 );
+ }
+
+ /**
+ * Test that a single list argument becomes independent inputs.
+ *
+ * @ticket 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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_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' );
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php
new file mode 100644
index 0000000000000..8cd40d36620ef
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php
@@ -0,0 +1,855 @@
+registry = $this->createMock( ProviderRegistry::class );
+ $this->registry->method( 'isProviderConfigured' )->willReturn( true );
+ }
+
+ /**
+ * 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_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 );
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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|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(
+ $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;
+ /** @var EmbeddingResult|Throwable */
+ private $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ $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
+ if ( $this->result instanceof Throwable ) {
+ throw $this->result;
+ }
+ return $this->result;
+ }
+ };
+ }
+
+ /**
+ * Test that WP_AI_Client_Embedding_Builder can be instantiated.
+ *
+ * @ticket 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ *
+ * @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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 calling a nonexistent method puts the builder in an error state.
+ *
+ * @ticket 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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.
+ *
+ * @ticket 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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_result() fires the embedding lifecycle action hooks in order.
+ *
+ * @ticket 65638
+ */
+ 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.
+ *
+ * @ticket 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 65638
+ */
+ 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 a throwable from the model is returned as a WP_Error with an embedding-prefixed code.
+ *
+ * @ticket 65638
+ *
+ * @dataProvider data_generate_embedding_result_maps_throwable_to_wp_error
+ *
+ * @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_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() );
+ $this->assertSame( $expected_status, $error->get_error_data()['status'] );
+ $this->assertSame( get_class( $throwable ), $error->get_error_data()['exception_class'] );
+ }
+
+ /**
+ * Data provider for {@see self::test_generate_embedding_result_maps_throwable_to_wp_error()}.
+ *
+ * @return 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 ),
+ '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 ),
+ '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 65638
+ * @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 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() {
+ $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 65638
+ * @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 e76918c2d02e4..bb1fe9c5baacd 100644
--- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
+++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
@@ -96,18 +96,30 @@ 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.
*/
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 );
$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 );
@@ -191,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 65638
+ */
+ 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.
*
@@ -376,15 +411,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' );
}
/**
@@ -1393,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 );
@@ -2620,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 );
@@ -2646,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(
@@ -2735,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 );
@@ -2918,7 +2945,7 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat
* @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 );