Commit 922506d4c5 for wordpress.org

commit 922506d4c5ffaffc004ea611d4c021dbb2ee15ee
Author: gziolo <gziolo@git.wordpress.org>
Date:   Tue Oct 13 09:54:04 2026 +0000

    AI: Introduce support for generating embeddings

    Update the bundled PHP AI Client from 1.3.1 to 1.5.0 and introduce `wp_ai_client_embedding()` and `WP_AI_Client_Embedding_Builder` for generating embedding vectors from text and file inputs. Accept multiple inputs as variadic arguments or a single list, and require an explicit model to keep vectors comparable.

    Extract shared prompt and embedding builder behavior into the internal `WP_AI_Client_Builder` base class, including fluent method proxying, `WP_Error` handling, request timeouts, and clone isolation. Add the `wp_ai_client_prevent_embedding` filter and embedding lifecycle actions, and pass the builder type to the existing timeout filter.

    Include tests for embedding input handling, model selection, error handling, filters, lifecycle actions, and clone isolation.

    Developed in: https://github.com/WordPress/wordpress-develop/pull/12530.

    Props jason_the_adams, gziolo, dkotter, jorgefilipecosta, extrachill, ugyensupport, jeffpaul, jorbin, saadtajik, vedantere, arkaprabhachowdhury, jamesbregenzer.
    Fixes #65638.


    Built from https://develop.svn.wordpress.org/trunk@64327


    git-svn-id: http://core.svn.wordpress.org/trunk@63477 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-includes/ai-client.php b/wp-includes/ai-client.php
index b38c7b7214..8cc772767a 100644
--- a/wp-includes/ai-client.php
+++ b/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/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php b/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php
index 0bfaae0163..6a53425b59 100644
--- a/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php
+++ b/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php
@@ -48,8 +48,12 @@ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface {
 		 *
 		 * - 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/wp-includes/ai-client/class-wp-ai-client-builder.php b/wp-includes/ai-client/class-wp-ai-client-builder.php
new file mode 100644
index 0000000000..db36442bb9
--- /dev/null
+++ b/wp-includes/ai-client/class-wp-ai-client-builder.php
@@ -0,0 +1,408 @@
+<?php
+/**
+ * WP AI Client: WP_AI_Client_Builder class
+ *
+ * @package WordPress
+ * @subpackage AI
+ * @since 7.2.0
+ */
+
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Common\Exception\TokenLimitReachedException;
+use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
+use WordPress\AiClient\Providers\Http\Exception\ClientException;
+use WordPress\AiClient\Providers\Http\Exception\NetworkException;
+use WordPress\AiClient\Providers\Http\Exception\ServerException;
+use WordPress\AiClient\Providers\ProviderRegistry;
+
+/**
+ * Base class for fluent AI client builders, returning WP_Error on failure.
+ *
+ * This class wraps a builder from the PHP AI Client SDK and adds
+ * WordPress-specific behavior shared by all builders: WP_Error handling
+ * instead of exceptions, and snake_case method naming proxied to the SDK's
+ * camelCase methods.
+ *
+ * Only the generating methods will return a WP_Error, to not break the fluent
+ * interface. As soon as any exception is caught in a chain of method calls,
+ * the returned instance will be in an error state, and all subsequent method
+ * calls will be no-ops that just return the same error state instance. Only
+ * when a generating method is called, the WP_Error will be returned. The
+ * support check methods are the exception to the no-op behavior: they return
+ * false rather than the error state instance.
+ *
+ * @since 7.2.0
+ * @internal Intended only as the shared base for the AI client builders in core.
+ *           It may gain abstract methods in the future, so it should not be extended.
+ * @access private
+ */
+abstract class WP_AI_Client_Builder {
+
+	/**
+	 * Wrapped builder instance from the PHP AI Client SDK.
+	 *
+	 * @since 7.0.0
+	 * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class.
+	 * @var object
+	 */
+	private object $builder;
+
+	/**
+	 * WordPress error instance, if any error occurred during method calls.
+	 *
+	 * @since 7.0.0
+	 * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class.
+	 * @var WP_Error|null
+	 */
+	private ?WP_Error $error = null;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 7.0.0
+	 * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class.
+	 *
+	 * @param ProviderRegistry $registry The provider registry for finding suitable models.
+	 * @param mixed            $input    Optional. Initial input content for the builder.
+	 *                                   See the child class for the supported types.
+	 *                                   Default null.
+	 */
+	public function __construct( ProviderRegistry $registry, $input = null ) {
+		try {
+			$this->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.' ),
+					'<code>wp_ai_client_default_request_timeout</code>'
+				),
+				'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<string, bool> 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<string, bool> 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<int, mixed> $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/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php
new file mode 100644
index 0000000000..eb041801c2
--- /dev/null
+++ b/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php
@@ -0,0 +1,165 @@
+<?php
+/**
+ * WP AI Client: WP_AI_Client_Embedding_Builder class
+ *
+ * @package WordPress
+ * @subpackage AI
+ * @since 7.2.0
+ */
+
+use WordPress\AiClient\AiClient;
+use WordPress\AiClient\Builders\EmbeddingBuilder;
+use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
+use WordPress\AiClient\Providers\ProviderRegistry;
+use WordPress\AiClient\Results\DTO\Embedding;
+use WordPress\AiClient\Results\DTO\EmbeddingResult;
+
+/**
+ * Fluent builder for generating embeddings, returning WP_Error on failure.
+ *
+ * This class provides a fluent interface for generating embedding vectors from
+ * text or file inputs. It wraps the PHP AI Client SDK's EmbeddingBuilder and
+ * adds WordPress-specific behavior including WP_Error handling instead of
+ * exceptions and snake_case method naming.
+ *
+ * A model must be specified via `using_model()` or `using_provider_model()`.
+ * Embedding vectors are only comparable to other vectors produced by the same
+ * model, so no model is selected automatically.
+ *
+ * Only the generating methods will return a WP_Error, to not break the fluent
+ * interface. As soon as any exception is caught in a chain of method calls,
+ * the returned instance will be in an error state, and all subsequent method
+ * calls will be no-ops that just return the same error state instance. Only
+ * when a generating method is called, the WP_Error will be returned. The
+ * support check methods are the exception to the no-op behavior: they return
+ * false rather than the error state instance.
+ *
+ * @since 7.2.0
+ *
+ * @see WP_AI_Client_Builder
+ *
+ * @phpstan-import-type EmbeddingInput from EmbeddingBuilder
+ *
+ * @method self with_input(...$input) Adds one or more inputs to embed.
+ * @method self using_dimensions(int $dimensions) Sets the embedding dimensions.
+ * @method self using_model(ModelInterface $model) Sets the model to use for generation.
+ * @method self using_provider_model(string $providerIdOrClassName, string $modelId) Sets the model to use for generation, by provider and model identifier.
+ * @method self using_model_config(ModelConfig $config) Sets the model configuration.
+ * @method self using_request_options(RequestOptions $options) Sets the request options for HTTP transport.
+ * @method bool is_supported() Checks whether the specified model supports the current inputs and configuration.
+ * @method EmbeddingResult|WP_Error generate_embedding_result() Generates an embedding result from the configured inputs.
+ * @method Embedding|WP_Error generate_embedding() Generates a single embedding from the configured input.
+ * @method list<Embedding>|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<string, bool>
+	 */
+	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<string, bool>
+	 */
+	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<EmbeddingInput>|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<string, bool> 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<string, bool> The support check methods map.
+	 */
+	protected function get_support_check_methods(): array {
+		return self::$support_check_methods;
+	}
+}
diff --git a/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
index b2205e3222..c793ef7751 100644
--- a/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
+++ b/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\Message;
 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 @@ use WordPress\AiClient\Tools\DTO\WebSearch;
  * 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 @@ use WordPress\AiClient\Tools\DTO\WebSearch;
  * @method File|WP_Error generate_video() Generates a video from the prompt.
  * @method list<File>|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.' ),
-					'<code>wp_ai_client_default_request_timeout</code>'
-				),
-				'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 @@ class WP_AI_Client_Prompt_Builder {
 	}

 	/**
-	 * 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<int, mixed> $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<string, bool> 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<string, bool> 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/wp-includes/php-ai-client/src/AiClient.php b/wp-includes/php-ai-client/src/AiClient.php
index 43be86aa0b..4ee6ec969f 100644
--- a/wp-includes/php-ai-client/src/AiClient.php
+++ b/wp-includes/php-ai-client/src/AiClient.php
@@ -5,14 +5,18 @@ namespace WordPress\AiClient;

 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 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult;
  * @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 @@ class AiClient
     {
         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<EmbeddingInput>|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 @@ class AiClient
         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<EmbeddingInput> $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<EmbeddingInput> $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<Embedding> 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 @@ class AiClient
      */
     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<EmbeddingInput> $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/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php b/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php
new file mode 100644
index 0000000000..a23a740ea2
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php
@@ -0,0 +1,534 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Builders;
+
+use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
+use WordPress\AiClient\Builders\Traits\ModelConfigurationTrait;
+use WordPress\AiClient\Common\AbstractEnum;
+use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Common\Exception\RuntimeException;
+use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent;
+use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent;
+use WordPress\AiClient\Files\DTO\File;
+use WordPress\AiClient\Messages\DTO\MessagePart;
+use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
+use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
+use WordPress\AiClient\Providers\ModelResolver;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
+use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
+use WordPress\AiClient\Providers\Models\DTO\RequiredOption;
+use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface;
+use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
+use WordPress\AiClient\Providers\ProviderRegistry;
+use WordPress\AiClient\Results\DTO\Embedding;
+use WordPress\AiClient\Results\DTO\EmbeddingResult;
+/**
+ * Fluent builder for generating embeddings.
+ *
+ * Embeddings transform inputs into vector representations rather than generating a conversational
+ * response. Each input is embedded independently, and the builder produces one embedding vector per
+ * input.
+ *
+ * Unlike {@see PromptBuilder}, this builder never selects a model on the caller's behalf. Embedding
+ * vectors are only comparable to other vectors produced by the same model, so a stored corpus is
+ * permanently bound to the model that created it. Automatically choosing a model could therefore
+ * silently invalidate existing vectors, for example when the registered providers change. The model
+ * must be specified via {@see self::usingModel()} or {@see self::usingProviderModel()}; the builder
+ * verifies that the given model can fulfill the request instead of searching for one that can.
+ *
+ * @since 1.4.0
+ *
+ * @phpstan-import-type MessagePartArrayShape from MessagePart
+ * @phpstan-import-type UnmetModelRequirementsShape from ModelRequirements
+ * @phpstan-import-type ProviderModelTuple from ModelResolver
+ *
+ * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape
+ */
+class EmbeddingBuilder
+{
+    use ModelConfigurationTrait;
+    /**
+     * @var string Message used when no model was specified.
+     */
+    private const NO_MODEL_MESSAGE = 'An embedding model must be specified. Embeddings are only comparable to ' . 'other embeddings from the same model, so no model is selected automatically. ' . 'Use usingModel() or usingProviderModel().';
+    /**
+     * @var ProviderRegistry The provider registry used to prepare the model.
+     */
+    protected ProviderRegistry $registry;
+    /**
+     * @var list<MessagePart> 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<EmbeddingInput>|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<EmbeddingInput> $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<Embedding> 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/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
index 87b0fedd4f..641d1915f1 100644
--- a/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
+++ b/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
@@ -4,6 +4,7 @@ declare (strict_types=1);
 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\MessagePart;
 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 @@ use WordPress\AiClient\Tools\DTO\WebSearch;
  */
 class PromptBuilder
 {
-    /**
-     * @var ProviderRegistry The provider registry for finding suitable models.
-     */
-    private ProviderRegistry $registry;
+    use ModelResolutionTrait;
     /**
      * @var list<Message> The messages in the conversation.
      */
     protected array $messages = [];
-    /**
-     * @var ModelInterface|null The model to use for generation.
-     */
-    protected ?ModelInterface $model = null;
-    /**
-     * @var list<string> 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 @@ class PromptBuilder
         $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 @@ class PromptBuilder
         $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 @@ class PromptBuilder
         $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 @@ class PromptBuilder
         // 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 @@ class PromptBuilder
         }
         // 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 @@ class PromptBuilder
         // 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 @@ class PromptBuilder
     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<string, array{0:string,1:string}> 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<ModelMetadata> $modelsMetadata The models metadata to map.
-     * @return array<string, array{0:string,1:string}> 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 @@ class PromptBuilder
      */
     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/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php b/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php
new file mode 100644
index 0000000000..1718a81f22
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php
@@ -0,0 +1,56 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Builders\Traits;
+
+use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
+/**
+ * Provides shared model configuration methods for builders.
+ *
+ * Builders that generate results from a model (e.g. {@see \WordPress\AiClient\Builders\PromptBuilder}
+ * and {@see \WordPress\AiClient\Builders\EmbeddingBuilder}) use this trait to accumulate model
+ * configuration, regardless of how they determine which model to use.
+ *
+ * @since 1.5.0
+ */
+trait ModelConfigurationTrait
+{
+    /**
+     * @var ModelConfig The model configuration.
+     */
+    protected ModelConfig $modelConfig;
+    /**
+     * 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
+    {
+        $this->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/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php b/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php
new file mode 100644
index 0000000000..a04abcb23c
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php
@@ -0,0 +1,92 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Builders\Traits;
+
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
+use WordPress\AiClient\Providers\ModelResolver;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+/**
+ * Provides shared model selection methods for builders.
+ *
+ * Builders that resolve a model from the available providers (e.g.
+ * {@see \WordPress\AiClient\Builders\PromptBuilder}) use this trait to expose a consistent fluent API
+ * for choosing a model, provider, preferences, and request options. Model selection state and logic
+ * live on the {@see ModelResolver} owned by the builder. Model configuration is handled by the
+ * composed {@see ModelConfigurationTrait}.
+ *
+ * @since 1.4.0
+ *
+ * @phpstan-import-type ProviderModelTuple from ModelResolver
+ */
+trait ModelResolutionTrait
+{
+    use \WordPress\AiClient\Builders\Traits\ModelConfigurationTrait;
+    /**
+     * @var ModelResolver The resolver that owns model selection state and logic.
+     */
+    protected ModelResolver $modelResolver;
+    /**
+     * 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->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/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php b/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php
new file mode 100644
index 0000000000..d8a4644cec
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php
@@ -0,0 +1,108 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Events;
+
+use WordPress\AiClient\Messages\DTO\MessagePart;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
+use WordPress\AiClient\Results\DTO\EmbeddingResult;
+/**
+ * Event dispatched after embeddings have been generated.
+ *
+ * @since 1.4.0
+ */
+class AfterGenerateEmbeddingEvent
+{
+    /**
+     * @var list<MessagePart> 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<MessagePart> $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<MessagePart> 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/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php b/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php
new file mode 100644
index 0000000000..9a6a6b4df0
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php
@@ -0,0 +1,89 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Events;
+
+use WordPress\AiClient\Messages\DTO\MessagePart;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
+/**
+ * Event dispatched before inputs are sent to an embedding generation model.
+ *
+ * @since 1.4.0
+ */
+class BeforeGenerateEmbeddingEvent
+{
+    /**
+     * @var list<MessagePart> 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<MessagePart> $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<MessagePart> 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/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php b/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php
index 21ea89364d..6cb7f05d99 100644
--- a/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php
+++ b/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php
@@ -5,7 +5,7 @@ namespace WordPress\AiClient\Files\Enums;

 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/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php b/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php
new file mode 100644
index 0000000000..a4bfec4cdb
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php
@@ -0,0 +1,48 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Messages\Util;
+
+use WordPress\AiClient\Messages\Enums\ModalityEnum;
+/**
+ * Utility class for building modality combinations.
+ *
+ * @since 1.4.0
+ */
+class ModalityCombinationsUtil
+{
+    /**
+     * Builds all modality combinations that always include the required modalities
+     * with every possible subset of the optional modalities.
+     *
+     * Uses binary representation to enumerate all 2^n subsets of the optional
+     * modalities. Each subset is merged with the required modalities to form one
+     * complete combination. Modality lists are unordered, so only unique
+     * combinations (not permutations) are produced.
+     *
+     * @since 1.4.0
+     *
+     * @param list<ModalityEnum> $required Required modalities included in every combination.
+     * @param list<ModalityEnum> $optional Optional modalities to generate subsets from.
+     * @return list<list<ModalityEnum>> 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/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php
index 4f7e2a338f..2989f326d6 100644
--- a/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php
+++ b/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<string, ModelMetadata|null>
+     */
+    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 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi
      */
     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 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi
      */
     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<string> $modelIds Model IDs for which to get explicit metadata.
+     * @return array<string, ModelMetadata> 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 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi
     {
         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<string> $modelIds The explicit model IDs.
+     * @return array<string, ModelMetadata> 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/wp-includes/php-ai-client/src/Providers/ModelResolver.php b/wp-includes/php-ai-client/src/Providers/ModelResolver.php
new file mode 100644
index 0000000000..975a2bb3d7
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Providers/ModelResolver.php
@@ -0,0 +1,378 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Providers;
+
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
+use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
+use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
+use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
+use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
+use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
+/**
+ * Resolves the concrete AI model to use based on selection preferences.
+ *
+ * This class owns all model-selection state and logic: an explicitly set model,
+ * ordered model preferences, a provider constraint, and HTTP request options. It
+ * is shared between the builders (via the model resolution trait) so that model
+ * selection behaves identically regardless of what is being generated.
+ *
+ * @since 1.4.0
+ *
+ * A ProviderModelTuple is a two-element list identifying a model by its provider: index 0 is
+ * the provider ID or provider class name, index 1 is the model ID. For example,
+ * ['google', 'gemini-embedding-001'].
+ *
+ * @phpstan-type ProviderModelTuple array{0: string, 1: string}
+ */
+class ModelResolver
+{
+    /**
+     * @var ProviderRegistry The provider registry for finding suitable models.
+     */
+    private \WordPress\AiClient\Providers\ProviderRegistry $registry;
+    /**
+     * @var ModelInterface|null The model to use for generation.
+     */
+    private ?ModelInterface $model = null;
+    /**
+     * @var list<string> 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<string, ProviderModelTuple> 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<ModelMetadata> $modelsMetadata The models metadata to map.
+     * @return array<string, ProviderModelTuple> 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/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php b/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php
index 00f1c69e00..fa465dbdf4 100644
--- a/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php
+++ b/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php
@@ -43,6 +43,7 @@ use WordPress\AiClient\Tools\DTO\WebSearch;
  *     outputMediaOrientation?: string,
  *     outputMediaAspectRatio?: string,
  *     outputSpeechVoice?: string,
+ *     dimensions?: int,
  *     customOptions?: array<string, mixed>
  * }
  *
@@ -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<string, mixed> Custom provider-specific options.
      */
@@ -682,6 +688,31 @@ class ModelConfig extends AbstractDataTransferObject
     {
         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 @@ class ModelConfig extends AbstractDataTransferObject
      */
     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 @@ class ModelConfig extends AbstractDataTransferObject
         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 @@ class ModelConfig extends AbstractDataTransferObject
         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/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php b/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php
index 54d0f7cb37..3d45c525f5 100644
--- a/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php
+++ b/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php
@@ -6,6 +6,7 @@ namespace WordPress\AiClient\Providers\Models\DTO;
 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 @@ use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
  *     requiredOptions: list<RequiredOptionArrayShape>
  * }
  *
+ * @phpstan-type UnmetModelRequirementsShape array{
+ *     capabilities: list<CapabilityEnum>,
+ *     options: list<RequiredOption>
+ * }
+ *
  * @extends AbstractDataTransferObject<ModelRequirementsArrayShape>
  */
 class ModelRequirements extends AbstractDataTransferObject
@@ -90,6 +96,23 @@ class ModelRequirements extends AbstractDataTransferObject
      * @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 @@ class ModelRequirements extends AbstractDataTransferObject
         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 @@ class ModelRequirements extends AbstractDataTransferObject
         $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 @@ class ModelRequirements extends AbstractDataTransferObject
         // 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<MessagePart> $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 @@ class ModelRequirements extends AbstractDataTransferObject
         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/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php
new file mode 100644
index 0000000000..e7155a2009
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php
@@ -0,0 +1,24 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts;
+
+use WordPress\AiClient\Messages\DTO\MessagePart;
+use WordPress\AiClient\Results\DTO\EmbeddingResult;
+/**
+ * Interface for models that support embedding generation.
+ *
+ * @since 1.4.0
+ */
+interface EmbeddingGenerationModelInterface
+{
+    /**
+     * Generates embeddings from one or more inputs.
+     *
+     * @since 1.4.0
+     *
+     * @param list<MessagePart> $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/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php b/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php
index c0d089153c..6fb78a69ee 100644
--- a/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php
+++ b/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php
@@ -19,6 +19,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
  * 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 @@ use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
  * @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/wp-includes/php-ai-client/src/Results/DTO/Embedding.php b/wp-includes/php-ai-client/src/Results/DTO/Embedding.php
new file mode 100644
index 0000000000..ecd02a013b
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Results/DTO/Embedding.php
@@ -0,0 +1,168 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Results\DTO;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use JsonSerializable;
+use Traversable;
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+/**
+ * Represents a single generated embedding vector.
+ *
+ * @since 1.4.0
+ *
+ * @phpstan-type EmbeddingList list<float|int>
+ *
+ * @implements IteratorAggregate<int, float|int>
+ */
+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<int, float|int> 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<string, mixed> 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/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php b/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php
new file mode 100644
index 0000000000..f914fd80a0
--- /dev/null
+++ b/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php
@@ -0,0 +1,225 @@
+<?php
+
+declare (strict_types=1);
+namespace WordPress\AiClient\Results\DTO;
+
+use WordPress\AiClient\Common\AbstractDataTransferObject;
+use WordPress\AiClient\Common\Exception\InvalidArgumentException;
+use WordPress\AiClient\Providers\DTO\ProviderMetadata;
+use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
+use WordPress\AiClient\Results\Contracts\ResultInterface;
+/**
+ * Represents the result of an embedding generation operation.
+ *
+ * @since 1.4.0
+ *
+ * @phpstan-import-type TokenUsageArrayShape from TokenUsage
+ * @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata
+ * @phpstan-import-type ModelMetadataArrayShape from ModelMetadata
+ * @phpstan-import-type EmbeddingList from Embedding
+ *
+ * @phpstan-type EmbeddingResultArrayShape array{
+ *     id: string,
+ *     embeddings: list<EmbeddingList>,
+ *     dimensions: int,
+ *     tokenUsage: TokenUsageArrayShape,
+ *     providerMetadata: ProviderMetadataArrayShape,
+ *     modelMetadata: ModelMetadataArrayShape,
+ *     additionalData?: array<string, mixed>
+ * }
+ *
+ * @extends AbstractDataTransferObject<EmbeddingResultArrayShape>
+ */
+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<Embedding>
+     */
+    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<string, mixed>
+     */
+    private array $additionalData;
+    /**
+     * Constructor.
+     *
+     * @since 1.4.0
+     *
+     * @param string $id Unique identifier for this result.
+     * @param list<Embedding|EmbeddingList> $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<string, mixed> $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<Embedding> 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<string, mixed> 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/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php b/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php
index 935459f44e..38063ae24b 100644
--- a/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php
+++ b/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php
@@ -8,14 +8,15 @@ use WordPress\AiClient\Common\AbstractDataTransferObject;
  * 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<string, mixed>
+ *     parameters?: array<string, mixed>,
+ *     annotations?: array<string, mixed>
  * }
  *
  * @extends AbstractDataTransferObject<FunctionDeclarationArrayShape>
@@ -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<string, mixed>|null The JSON schema for the function parameters.
      */
     private ?array $parameters;
+    /**
+     * @var array<string, mixed> 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<string, mixed>|null $parameters The JSON schema for the function parameters.
+     * @param array<string, mixed> $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 @@ class FunctionDeclaration extends AbstractDataTransferObject
     {
         return $this->parameters;
     }
+    /**
+     * Gets the function annotations.
+     *
+     * @since 1.5.0
+     *
+     * @return array<string, mixed> The annotations, or an empty array if none were provided.
+     */
+    public function getAnnotations(): array
+    {
+        return $this->annotations;
+    }
     /**
      * {@inheritDoc}
      *
@@ -92,7 +112,7 @@ class FunctionDeclaration extends AbstractDataTransferObject
      */
     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 @@ class FunctionDeclaration extends AbstractDataTransferObject
         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 @@ class FunctionDeclaration extends AbstractDataTransferObject
     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/wp-includes/version.php b/wp-includes/version.php
index d616f6a996..1a8024ecfe 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64326';
+$wp_version = '7.2-alpha-64327';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/wp-settings.php b/wp-settings.php
index 8c9ea4a86b..07756eb048 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -295,7 +295,9 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-cache.php';
 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';