diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index b38c7b721416d..8cc772767a098 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -8,6 +8,7 @@ */ use WordPress\AiClient\AiClient; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -60,3 +61,40 @@ function wp_supports_ai(): bool { function wp_ai_client_prompt( $prompt = null ): WP_AI_Client_Prompt_Builder { return new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), $prompt ); } + +/** + * Creates a new AI embedding builder using the default provider registry. + * + * This is the main entry point for generating embeddings in WordPress. It returns + * a fluent builder that can be used to configure and generate embedding vectors. + * + * Each input can be provided as a simple string for basic text inputs, or as more + * complex types. Pass multiple arguments, or a single list of inputs, to embed + * multiple inputs at once, each producing its own embedding vector. + * + * A model must be specified on the returned builder via `using_model()` or + * `using_provider_model()`. Embedding vectors are only comparable to other + * vectors produced by the same model, so no model is selected automatically. + * + * @since 7.2.0 + * + * @param string|MessagePart|File|array ...$input Optional. Initial input(s) to embed. + * A string for simple text inputs, + * a MessagePart or File object for + * structured content, or an array for + * a message part array shape. + * Alternatively, a single list of any + * of these to embed multiple inputs. + * @return WP_AI_Client_Embedding_Builder The embedding builder instance. + */ +function wp_ai_client_embedding( ...$input ): WP_AI_Client_Embedding_Builder { + /* + * A single list argument is a list of inputs. It cannot be a single input, since + * the only array input is a message part array shape, which is never a list. + */ + if ( 1 === count( $input ) && isset( $input[0] ) && is_array( $input[0] ) && array_is_list( $input[0] ) ) { + $input = $input[0]; + } + + return new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), array() === $input ? null : $input ); +} diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php index 0bfaae0163330..6a53425b59bd9 100644 --- a/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-event-dispatcher.php @@ -48,8 +48,12 @@ public function dispatch( object $event ): object { * * - wp_ai_client_before_generate_result * - wp_ai_client_after_generate_result + * - wp_ai_client_before_generate_embedding + * - wp_ai_client_after_generate_embedding * * @since 7.0.0 + * @since 7.2.0 Added the `wp_ai_client_before_generate_embedding` and + * `wp_ai_client_after_generate_embedding` action hooks. * * @param object $event The event object. */ diff --git a/src/wp-includes/ai-client/class-wp-ai-client-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-builder.php new file mode 100644 index 0000000000000..db36442bb9f31 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-builder.php @@ -0,0 +1,408 @@ +builder = $this->create_sdk_builder( $registry, $input ); + } catch ( Throwable $e ) { + $this->builder = $this->create_sdk_builder( $registry, null ); + $this->error = $this->throwable_to_wp_error( $e ); + } + + $default_timeout = 30.0; + + /** + * Filters the default request timeout in seconds for AI Client HTTP requests. + * + * @since 7.0.0 + * @since 7.2.0 Added the `$builder_type` parameter. + * + * @param float $default_timeout The default timeout in seconds. + * @param string $builder_type The class name of the builder the timeout applies to, + * e.g. 'WP_AI_Client_Prompt_Builder' or 'WP_AI_Client_Embedding_Builder'. + */ + $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout, get_class( $this ) ); + if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { + $default_timeout = (float) $filtered_default_timeout; + } else { + _doing_it_wrong( + get_class( $this ) . '::__construct', + sprintf( + /* translators: %s: wp_ai_client_default_request_timeout */ + __( 'The %s filter must return a non-negative number.' ), + 'wp_ai_client_default_request_timeout' + ), + '7.0.0' + ); + } + + $this->builder->usingRequestOptions( + RequestOptions::fromArray( + array( + RequestOptions::KEY_TIMEOUT => $default_timeout, + ) + ) + ); + } + + /** + * Clones the wrapped builder alongside this instance. + * + * The wrapped builder mutates its own state, so without this a clone would + * share that state with the original and any change made to one would be + * visible in the other. + * + * @since 7.2.0 + */ + public function __clone() { + $this->builder = clone $this->builder; + + if ( null !== $this->error ) { + $this->error = clone $this->error; + } + } + + /** + * Creates the wrapped builder instance from the PHP AI Client SDK. + * + * @since 7.2.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param mixed $input Initial input content for the builder, or null. + * @return object The SDK builder instance. + */ + abstract protected function create_sdk_builder( ProviderRegistry $registry, $input ): object; + + /** + * Retrieves the prefix used for WP_Error codes created by this builder. + * + * @since 7.2.0 + * + * @return string The error code prefix, e.g. 'prompt' or 'embedding'. + */ + abstract protected function get_error_code_prefix(): string; + + /** + * Retrieves the error message to use when execution is prevented by a filter. + * + * @since 7.2.0 + * + * @return string The translated error message. + */ + abstract protected function get_prevented_error_message(): string; + + /** + * Checks whether execution is prevented by the builder's prevent filter. + * + * Child classes apply their specific filter, passing a clone of the builder + * instance for read-only inspection. + * + * @since 7.2.0 + * + * @return bool Whether execution is prevented. + */ + abstract protected function is_prevented_by_filter(): bool; + + /** + * Retrieves the methods that generate a result from the builder. + * + * Structured as a map of method name to true for faster lookups. + * + * @since 7.2.0 + * + * @return array The generating methods map. + */ + abstract protected function get_generating_methods(): array; + + /** + * Retrieves the methods that check whether the builder input is supported. + * + * Structured as a map of method name to true for faster lookups. + * + * @since 7.2.0 + * + * @return array The support check methods map. + */ + abstract protected function get_support_check_methods(): array; + + /** + * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * + * This allows WordPress developers to use snake_case naming conventions. It catches + * any exceptions thrown, stores them, and returns a WP_Error when a terminate method + * is called, or false when a support check method is called. + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $name The method name in snake_case. + * @param array $arguments The method arguments. + * @return mixed The result of the method call. + */ + public function __call( string $name, array $arguments ) { + /* + * If an error occurred in a previous method call, either return the error for terminate methods, + * or return the same instance for other methods to maintain the fluent interface. + */ + if ( null !== $this->error ) { + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + if ( $this->is_support_check_method( $name ) ) { + return false; + } + return $this; + } + + // Check if execution should be prevented for support check and generating methods. + if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + // If AI is not supported, then there's no need to apply the filter as execution will be prevented anyway. + $is_ai_disabled = ! wp_supports_ai(); + $prevent = $is_ai_disabled; + if ( ! $prevent ) { + $prevent = $this->is_prevented_by_filter(); + } + + if ( $prevent ) { + // For support check methods, return false. + if ( $this->is_support_check_method( $name ) ) { + return false; + } + + $error_message = $is_ai_disabled + ? __( 'AI features are not supported in this environment.' ) + : $this->get_prevented_error_message(); + + // For generating methods, create a WP_Error. + $this->error = new WP_Error( + $this->get_error_code_prefix() . '_prevented', + $error_message, + array( + 'status' => 503, + ) + ); + + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + try { + $callable = $this->get_builder_callable( $name ); + $result = $callable( ...$arguments ); + + // If the result is the wrapped SDK builder, return the current instance to allow method chaining. + if ( $result instanceof $this->builder ) { + return $this; + } + + return $result; + } catch ( Throwable $e ) { + $this->error = $this->throwable_to_wp_error( $e ); + + if ( $this->is_generating_method( $name ) ) { + return $this->error; + } + if ( $this->is_support_check_method( $name ) ) { + return false; + } + return $this; + } + } + + /** + * Converts a throwable into a WP_Error with a structured error code and message. + * + * This method maps different throwable types to specific WP_Error codes and HTTP status codes. + * The presence of the status codes means these WP_Error objects can be easily used in REST API responses + * or other contexts where HTTP semantics are relevant. + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param Throwable $throwable The throwable to convert. + * @return WP_Error The resulting WP_Error object. + */ + private function throwable_to_wp_error( Throwable $throwable ): WP_Error { + $prefix = $this->get_error_code_prefix(); + + if ( $throwable instanceof NetworkException ) { + $error_code = $prefix . '_network_error'; + $status_code = 503; + } elseif ( $throwable instanceof ClientException ) { + // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = $prefix . '_client_error'; + $status_code = $throwable->getCode() ? $throwable->getCode() : 400; + } elseif ( $throwable instanceof ServerException ) { + // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. + $error_code = $prefix . '_upstream_server_error'; + $status_code = $throwable->getCode() ? $throwable->getCode() : 500; + } elseif ( $throwable instanceof TokenLimitReachedException ) { + $error_code = $prefix . '_token_limit_reached'; + $status_code = 400; + } elseif ( $throwable instanceof InvalidArgumentException ) { + $error_code = $prefix . '_invalid_argument'; + $status_code = 400; + } else { + $error_code = $prefix . '_builder_error'; + $status_code = 500; + } + + return new WP_Error( + $error_code, + $throwable->getMessage(), + array( + 'status' => $status_code, + 'exception_class' => get_class( $throwable ), + ) + ); + } + + /** + * Checks if a method name is a support check method (is_supported*). + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * and changed from static to instance method. + * + * @param string $name The method name. + * @return bool True if the method is a support check method, false otherwise. + */ + private function is_support_check_method( string $name ): bool { + $methods = $this->get_support_check_methods(); + return isset( $methods[ $name ] ); + } + + /** + * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class, + * and changed from static to instance method. + * + * @param string $name The method name. + * @return bool True if the method is a generating method, false otherwise. + */ + private function is_generating_method( string $name ): bool { + $methods = $this->get_generating_methods(); + return isset( $methods[ $name ] ); + } + + /** + * Retrieves a callable for a given PHP AI Client SDK builder method name. + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $name The method name in snake_case. + * @return callable The callable for the specified method. + * + * @throws BadMethodCallException If the method does not exist. + */ + protected function get_builder_callable( string $name ): callable { + $camel_case_name = $this->snake_to_camel_case( $name ); + + $method = array( $this->builder, $camel_case_name ); + if ( ! is_callable( $method ) ) { + throw new BadMethodCallException( + sprintf( + /* translators: 1: Method name. 2: Class name. */ + __( 'Method %1$s does not exist on %2$s.' ), + $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + ) + ); + } + + return $method; + } + + /** + * Converts snake_case to camelCase. + * + * @since 7.0.0 + * @since 7.2.0 Moved from `WP_AI_Client_Prompt_Builder` to the `WP_AI_Client_Builder` base class. + * + * @param string $snake_case The snake_case string. + * @return string The camelCase string. + */ + private function snake_to_camel_case( string $snake_case ): string { + $parts = explode( '_', $snake_case ); + + $camel_case = $parts[0]; + $parts_count = count( $parts ); + for ( $i = 1; $i < $parts_count; $i++ ) { + $camel_case .= ucfirst( $parts[ $i ] ); + } + + return $camel_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php new file mode 100644 index 0000000000000..eb041801c2887 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-embedding-builder.php @@ -0,0 +1,165 @@ +|WP_Error generate_embeddings() Generates embeddings from the configured inputs. + */ +class WP_AI_Client_Embedding_Builder extends WP_AI_Client_Builder { + + /** + * List of methods that generate embeddings from the inputs. + * + * Structured as a map for faster lookups. + * + * @since 7.2.0 + * @var array + */ + private static array $generating_methods = array( + 'generate_embedding_result' => true, + 'generate_embedding' => true, + 'generate_embeddings' => true, + ); + + /** + * List of methods that check whether the embedding generation is supported. + * + * Structured as a map for faster lookups. + * + * @since 7.2.0 + * @var array + */ + private static array $support_check_methods = array( + 'is_supported' => true, + ); + + /** + * Creates the wrapped embedding builder instance from the PHP AI Client SDK. + * + * @since 7.2.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param EmbeddingInput|list|null $input Initial input(s) to embed, or null. + * A string for simple text inputs, + * a MessagePart or File object for + * structured content, an array for a + * message part array shape, or a list + * of any of these to embed multiple + * inputs. + * @return EmbeddingBuilder The SDK embedding builder instance. + */ + protected function create_sdk_builder( ProviderRegistry $registry, $input ): object { + return new EmbeddingBuilder( $registry, $input, AiClient::getEventDispatcher() ); + } + + /** + * Retrieves the prefix used for WP_Error codes created by this builder. + * + * @since 7.2.0 + * + * @return string The error code prefix. + */ + protected function get_error_code_prefix(): string { + return 'embedding'; + } + + /** + * Retrieves the error message to use when the embedding generation is prevented by a filter. + * + * @since 7.2.0 + * + * @return string The translated error message. + */ + protected function get_prevented_error_message(): string { + return __( 'Embedding generation was prevented by a filter.' ); + } + + /** + * Checks whether the embedding generation is prevented by the `wp_ai_client_prevent_embedding` filter. + * + * @since 7.2.0 + * + * @return bool Whether the embedding generation is prevented. + */ + protected function is_prevented_by_filter(): bool { + /** + * Filters whether to prevent the embedding generation from being executed. + * + * @since 7.2.0 + * + * @param bool $prevent Whether to prevent the embedding generation. Default false. + * @param WP_AI_Client_Embedding_Builder $builder A clone of the embedding builder instance (read-only). + */ + return (bool) apply_filters( 'wp_ai_client_prevent_embedding', false, clone $this ); + } + + /** + * Retrieves the methods that generate embeddings from the inputs. + * + * @since 7.2.0 + * + * @return array The generating methods map. + */ + protected function get_generating_methods(): array { + return self::$generating_methods; + } + + /** + * Retrieves the methods that check whether the embedding generation is supported. + * + * @since 7.2.0 + * + * @return array The support check methods map. + */ + protected function get_support_check_methods(): array { + return self::$support_check_methods; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index b2205e32222c7..c793ef77517c5 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -9,8 +9,6 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; @@ -18,9 +16,6 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; -use WordPress\AiClient\Providers\Http\Exception\ClientException; -use WordPress\AiClient\Providers\Http\Exception\NetworkException; -use WordPress\AiClient\Providers\Http\Exception\ServerException; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -48,6 +43,9 @@ * false rather than the error state instance. * * @since 7.0.0 + * @since 7.2.0 Now extends the `WP_AI_Client_Builder` base class. + * + * @see WP_AI_Client_Builder * * @phpstan-import-type Prompt from PromptBuilder * @@ -106,23 +104,7 @@ * @method File|WP_Error generate_video() Generates a video from the prompt. * @method list|WP_Error generate_videos(?int $candidateCount = null) Generates multiple videos from the prompt. */ -class WP_AI_Client_Prompt_Builder { - - /** - * Wrapped prompt builder instance from the PHP AI Client SDK. - * - * @since 7.0.0 - * @var PromptBuilder - */ - private PromptBuilder $builder; - - /** - * WordPress error instance, if any error occurred during method calls. - * - * @since 7.0.0 - * @var WP_Error|null - */ - private ?WP_Error $error = null; +class WP_AI_Client_Prompt_Builder extends WP_AI_Client_Builder { /** * List of methods that generate a result from the prompt. @@ -170,78 +152,6 @@ class WP_AI_Client_Prompt_Builder { 'is_supported_for_embedding_generation' => true, ); - /** - * Constructor. - * - * @since 7.0.0 - * - * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param Prompt $prompt Optional. Initial prompt content. - * A string for simple text prompts, - * a MessagePart or Message object for - * structured content, an array for a - * message array shape, or a list of - * parts or messages for multi-turn - * conversations. Default null. - */ - public function __construct( ProviderRegistry $registry, $prompt = null ) { - try { - $this->builder = new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); - } catch ( Throwable $e ) { - $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() ); - $this->error = $this->throwable_to_wp_error( $e ); - } - - $default_timeout = 30.0; - - /** - * Filters the default request timeout in seconds for AI Client HTTP requests. - * - * @since 7.0.0 - * - * @param float $default_timeout The default timeout in seconds. - */ - $filtered_default_timeout = apply_filters( 'wp_ai_client_default_request_timeout', $default_timeout ); - if ( is_numeric( $filtered_default_timeout ) && (float) $filtered_default_timeout >= 0.0 ) { - $default_timeout = (float) $filtered_default_timeout; - } else { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: wp_ai_client_default_request_timeout */ - __( 'The %s filter must return a non-negative number.' ), - 'wp_ai_client_default_request_timeout' - ), - '7.0.0' - ); - } - - $this->builder->usingRequestOptions( - RequestOptions::fromArray( - array( - RequestOptions::KEY_TIMEOUT => $default_timeout, - ) - ) - ); - } - - /** - * Clones the wrapped prompt builder alongside this instance. - * - * The wrapped builder mutates its own state, so without this a clone would - * share that state with the original and any change made to one would be - * visible in the other. - * - * @since 7.2.0 - */ - public function __clone() { - $this->builder = clone $this->builder; - - if ( null !== $this->error ) { - $this->error = clone $this->error; - } - } - /** * Registers WordPress abilities as function declarations for the AI model. * @@ -297,213 +207,84 @@ public function using_abilities( ...$abilities ): self { } /** - * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. - * - * This allows WordPress developers to use snake_case naming conventions. It catches - * any exceptions thrown, stores them, and returns a WP_Error when a terminate method - * is called, or false when a support check method is called. + * Creates the wrapped prompt builder instance from the PHP AI Client SDK. * - * @since 7.0.0 + * @since 7.2.0 * - * @param string $name The method name in snake_case. - * @param array $arguments The method arguments. - * @return mixed The result of the method call. + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param Prompt $prompt Initial prompt content, or null. + * A string for simple text prompts, + * a MessagePart or Message object for + * structured content, an array for a + * message array shape, or a list of + * parts or messages for multi-turn + * conversations. + * @return PromptBuilder The SDK prompt builder instance. */ - public function __call( string $name, array $arguments ) { - /* - * If an error occurred in a previous method call, either return the error for terminate methods, - * or return the same instance for other methods to maintain the fluent interface. - */ - if ( null !== $this->error ) { - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - if ( self::is_support_check_method( $name ) ) { - return false; - } - return $this; - } - - // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. - if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { - // If AI is not supported, then there's no need to apply the filter as the prompt will be prevented anyway. - $is_ai_disabled = ! wp_supports_ai(); - $prevent = $is_ai_disabled; - if ( ! $prevent ) { - /** - * Filters whether to prevent the prompt from being executed. - * - * @since 7.0.0 - * - * @param bool $prevent Whether to prevent the prompt. Default false. - * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). - */ - $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); - } - - if ( $prevent ) { - // For is_supported* methods, return false. - if ( self::is_support_check_method( $name ) ) { - return false; - } - - $error_message = $is_ai_disabled - ? __( 'AI features are not supported in this environment.' ) - : __( 'Prompt execution was prevented by a filter.' ); - - // For generate_* and convert_text_to_speech* methods, create a WP_Error. - $this->error = new WP_Error( - 'prompt_prevented', - $error_message, - array( - 'status' => 503, - ) - ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - return $this; - } - } - - try { - $callable = $this->get_builder_callable( $name ); - $result = $callable( ...$arguments ); - - // If the result is a PromptBuilder, return the current instance to allow method chaining. - if ( $result instanceof PromptBuilder ) { - return $this; - } - - return $result; - } catch ( Throwable $e ) { - $this->error = $this->throwable_to_wp_error( $e ); - - if ( self::is_generating_method( $name ) ) { - return $this->error; - } - if ( self::is_support_check_method( $name ) ) { - return false; - } - return $this; - } + protected function create_sdk_builder( ProviderRegistry $registry, $prompt ): object { + return new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); } /** - * Converts a throwable into a WP_Error with a structured error code and message. - * - * This method maps different throwable types to specific WP_Error codes and HTTP status codes. - * The presence of the status codes means these WP_Error objects can be easily used in REST API responses - * or other contexts where HTTP semantics are relevant. + * Retrieves the prefix used for WP_Error codes created by this builder. * - * @since 7.0.0 + * @since 7.2.0 * - * @param Throwable $throwable The throwable to convert. - * @return WP_Error The resulting WP_Error object. + * @return string The error code prefix. */ - private function throwable_to_wp_error( Throwable $throwable ): WP_Error { - if ( $throwable instanceof NetworkException ) { - $error_code = 'prompt_network_error'; - $status_code = 503; - } elseif ( $throwable instanceof ClientException ) { - // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'prompt_client_error'; - $status_code = $throwable->getCode() ? $throwable->getCode() : 400; - } elseif ( $throwable instanceof ServerException ) { - // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. - $error_code = 'prompt_upstream_server_error'; - $status_code = $throwable->getCode() ? $throwable->getCode() : 500; - } elseif ( $throwable instanceof TokenLimitReachedException ) { - $error_code = 'prompt_token_limit_reached'; - $status_code = 400; - } elseif ( $throwable instanceof InvalidArgumentException ) { - $error_code = 'prompt_invalid_argument'; - $status_code = 400; - } else { - $error_code = 'prompt_builder_error'; - $status_code = 500; - } - - return new WP_Error( - $error_code, - $throwable->getMessage(), - array( - 'status' => $status_code, - 'exception_class' => get_class( $throwable ), - ) - ); + protected function get_error_code_prefix(): string { + return 'prompt'; } /** - * Checks if a method name is a support check method (is_supported*). + * Retrieves the error message to use when the prompt is prevented by a filter. * - * @since 7.0.0 + * @since 7.2.0 * - * @param string $name The method name. - * @return bool True if the method is a support check method, false otherwise. + * @return string The translated error message. */ - private static function is_support_check_method( string $name ): bool { - return isset( self::$support_check_methods[ $name ] ); + protected function get_prevented_error_message(): string { + return __( 'Prompt execution was prevented by a filter.' ); } /** - * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * Checks whether the prompt is prevented by the `wp_ai_client_prevent_prompt` filter. * - * @since 7.0.0 + * @since 7.2.0 * - * @param string $name The method name. - * @return bool True if the method is a generating method, false otherwise. + * @return bool Whether the prompt is prevented. */ - private static function is_generating_method( string $name ): bool { - return isset( self::$generating_methods[ $name ] ); + protected function is_prevented_by_filter(): bool { + /** + * Filters whether to prevent the prompt from being executed. + * + * @since 7.0.0 + * + * @param bool $prevent Whether to prevent the prompt. Default false. + * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). + */ + return (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); } /** - * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. + * Retrieves the methods that generate a result from the prompt. * - * @since 7.0.0 - * - * @param string $name The method name in snake_case. - * @return callable The callable for the specified method. + * @since 7.2.0 * - * @throws BadMethodCallException If the method does not exist. + * @return array The generating methods map. */ - protected function get_builder_callable( string $name ): callable { - $camel_case_name = $this->snake_to_camel_case( $name ); - - $method = array( $this->builder, $camel_case_name ); - if ( ! is_callable( $method ) ) { - throw new BadMethodCallException( - sprintf( - /* translators: 1: Method name. 2: Class name. */ - __( 'Method %1$s does not exist on %2$s.' ), - $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - ) - ); - } - - return $method; + protected function get_generating_methods(): array { + return self::$generating_methods; } /** - * Converts snake_case to camelCase. + * Retrieves the methods that check whether the prompt is supported. * - * @since 7.0.0 + * @since 7.2.0 * - * @param string $snake_case The snake_case string. - * @return string The camelCase string. + * @return array The support check methods map. */ - private function snake_to_camel_case( string $snake_case ): string { - $parts = explode( '_', $snake_case ); - - $camel_case = $parts[0]; - $parts_count = count( $parts ); - for ( $i = 1; $i < $parts_count; $i++ ) { - $camel_case .= ucfirst( $parts[ $i ] ); - } - - return $camel_case; + protected function get_support_check_methods(): array { + return self::$support_check_methods; } } diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index 43be86aa0be4d..4ee6ec969f34d 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -5,14 +5,18 @@ use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface; +use WordPress\AiClient\Builders\EmbeddingBuilder; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Contracts\ProviderInterface; +use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\DTO\Embedding; +use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -76,6 +80,8 @@ * @since 0.1.0 * * @phpstan-import-type Prompt from PromptBuilder + * @phpstan-import-type EmbeddingInput from EmbeddingBuilder + * @phpstan-import-type ProviderModelTuple from ModelResolver * * phpcs:ignore Generic.Files.LineLength.TooLong */ @@ -84,7 +90,7 @@ class AiClient /** * @var string The version of the AI Client. */ - public const VERSION = '1.3.1'; + public const VERSION = '1.5.0'; /** * @var ProviderRegistry|null The default provider registry instance. */ @@ -211,6 +217,27 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null { return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher); } + /** + * Creates an embedding builder for fluent embedding generation. + * + * Accepts a single input or a list of inputs; each input is embedded independently. Chain an + * embedding generation method such as generateEmbedding() or generateEmbeddings() to produce + * one vector per input. + * + * A model must be specified on the returned builder via usingModel() or usingProviderModel(). + * Embedding vectors are only comparable to other vectors from the same model, so no model is + * selected automatically. + * + * @since 1.4.0 + * + * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return EmbeddingBuilder The embedding builder instance. + */ + public static function input($input = null, ?ProviderRegistry $registry = null): EmbeddingBuilder + { + return new EmbeddingBuilder($registry ?? self::defaultRegistry(), $input, self::$eventDispatcher); + } /** * Generates content using a unified API that automatically detects model capabilities. * @@ -334,6 +361,63 @@ public static function generateVideoResult($prompt, $modelOrConfig = null, ?Prov self::validateModelOrConfigParameter($modelOrConfig); return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult(); } + /** + * Generates embeddings using the traditional API approach. + * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * + * @since 1.4.0 + * + * @param EmbeddingInput|list $input The input(s) to embed. + * @param ModelInterface|ProviderModelTuple $model The model to use. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return EmbeddingResult The embedding result. + * + * @throws \InvalidArgumentException If the input or model format is invalid, or if the model + * cannot fulfill the request. + */ + public static function generateEmbeddingResult($input, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): EmbeddingResult + { + return self::getConfiguredEmbeddingBuilder($input, $model, $modelConfig, $registry)->generateEmbeddingResult(); + } + /** + * Generates an embedding using the traditional API approach. + * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * + * @since 1.4.0 + * + * @param EmbeddingInput $input The input to embed. + * @param ModelInterface|ProviderModelTuple $model The model to use. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return Embedding The generated embedding vector. + */ + public static function generateEmbedding($input, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): Embedding + { + return self::generateEmbeddingResult($input, $model, $modelConfig, $registry)->getEmbedding(); + } + /** + * Generates embeddings for a list of inputs using the traditional API approach. + * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * + * @since 1.4.0 + * + * @param list $inputs The inputs to embed. + * @param ModelInterface|ProviderModelTuple $model The model to use. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return list The generated embedding vectors. + */ + public static function generateEmbeddings(array $inputs, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): array + { + return self::getConfiguredEmbeddingBuilder($inputs, $model, $modelConfig, $registry)->generateEmbeddings(); + } /** * Creates a new message builder for fluent API usage. * @@ -375,7 +459,49 @@ private static function validateModelOrConfigParameter($modelOrConfig): void */ private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder { - $builder = self::prompt($prompt, $registry); + return self::applyModelOrConfig(self::prompt($prompt, $registry), $modelOrConfig); + } + /** + * Configures an EmbeddingBuilder with the required model and optional configuration. + * + * @param EmbeddingInput|list $input The input(s) to embed. + * @param mixed $model The model to use, expected to be a ModelInterface instance or a + * ProviderModelTuple; any other value is rejected. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. + * @param ProviderRegistry|null $registry Optional custom registry to use. + * @return EmbeddingBuilder Configured embedding builder. + * @throws InvalidArgumentException If the model parameter is not of a supported type. + */ + private static function getConfiguredEmbeddingBuilder($input, $model, ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null): EmbeddingBuilder + { + $builder = self::input($input, $registry); + // The builder's configuration takes precedence over the model's own configuration. + if ($modelConfig !== null) { + $builder->usingModelConfig($modelConfig); + } + if ($model instanceof ModelInterface) { + return $builder->usingModel($model); + } + if (is_array($model) && array_is_list($model) && count($model) === 2 && is_string($model[0]) && is_string($model[1])) { + return $builder->usingProviderModel($model[0], $model[1]); + } + throw new InvalidArgumentException('Model must be a ModelInterface instance or a [provider ID, model ID] tuple. ' . 'Embeddings are only comparable to other embeddings from the same model, so a model ' . 'is required. ' . sprintf('Received: %s', is_object($model) ? get_class($model) : gettype($model))); + } + /** + * Applies a model or model configuration to a builder. + * + * Works with any builder that exposes the shared model resolution methods + * (see {@see \WordPress\AiClient\Builders\Traits\ModelResolutionTrait}). + * + * @template T of PromptBuilder + * + * @param T $builder The builder to configure. + * @param ModelInterface|ModelConfig|null $modelOrConfig Specific model, model configuration, + * or null for default discovery. + * @return T The configured builder. + */ + private static function applyModelOrConfig($builder, $modelOrConfig) + { if ($modelOrConfig instanceof ModelInterface) { $builder->usingModel($modelOrConfig); } elseif ($modelOrConfig instanceof ModelConfig) { diff --git a/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php new file mode 100644 index 0000000000000..a23a740ea277a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/EmbeddingBuilder.php @@ -0,0 +1,534 @@ + The inputs to embed. + */ + protected array $inputs = []; + /** + * @var ModelInterface|null The explicitly provided model, if any. + */ + protected ?ModelInterface $model = null; + /** + * @var ProviderModelTuple|null The provider ID or class name and model ID, if any. + */ + protected ?array $providerModel = null; + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + protected ?RequestOptions $requestOptions = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for embedding lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param ProviderRegistry $registry The provider registry used to prepare the model. + * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. + */ + public function __construct(ProviderRegistry $registry, $input = null, ?EventDispatcherInterface $eventDispatcher = null) + { + $this->registry = $registry; + $this->modelConfig = new ModelConfig(); + $this->eventDispatcher = $eventDispatcher; + if ($input === null) { + return; + } + if (is_array($input) && array_is_list($input)) { + /** @var list $input */ + $this->withInput(...$input); + return; + } + /** @var EmbeddingInput $input */ + $this->withInput($input); + } + /** + * Creates a deep clone of this builder. + * + * Clones the inputs, model configuration, and request options. Service objects (registry, model, + * event dispatcher) are intentionally NOT cloned as they are shared dependencies. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $this->inputs = $clonedInputs; + $this->modelConfig = clone $this->modelConfig; + if ($this->requestOptions !== null) { + $this->requestOptions = clone $this->requestOptions; + } + // Note: $registry, $model, and $eventDispatcher are service objects and are intentionally + // NOT cloned - they should be shared references. + } + /** + * Adds one or more inputs to embed. + * + * @since 1.4.0 + * + * @param EmbeddingInput ...$input The inputs to embed, each treated as an independent input. + * @return self + * @throws InvalidArgumentException If no inputs are provided or an input is invalid. + */ + public function withInput(...$input): self + { + if ($input === []) { + throw new InvalidArgumentException('At least one input must be provided.'); + } + foreach ($input as $singleInput) { + $this->inputs[] = $this->parseInput($singleInput); + } + return $this; + } + /** + * Sets the model to use for embedding generation. + * + * The model's configuration will be merged with the builder's configuration, + * with the builder's configuration taking precedence for any overlapping settings. The merge + * happens when the model is used rather than here, so replacing the model replaces its + * configuration along with it. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + $this->providerModel = null; + return $this; + } + /** + * Sets the model to use for embedding generation, by provider and model identifier. + * + * This is an alternative to {@see self::usingModel()} that does not require the caller to + * reference a provider class directly. The model is retrieved from the registry when the + * embeddings are generated, so it receives any configuration set afterwards. + * + * @since 1.5.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @return self + * @throws InvalidArgumentException If the provider or model identifier is empty. + */ + public function usingProviderModel(string $providerIdOrClassName, string $modelId): self + { + if (trim($providerIdOrClassName) === '') { + throw new InvalidArgumentException('Provider identifier cannot be empty.'); + } + if (trim($modelId) === '') { + throw new InvalidArgumentException('Model identifier cannot be empty.'); + } + $this->providerModel = [$providerIdOrClassName, $modelId]; + $this->model = null; + return $this; + } + /** + * Sets the request options for HTTP transport. + * + * @since 1.4.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->requestOptions = $requestOptions; + return $this; + } + /** + * Sets the embedding dimensions. + * + * @since 1.4.0 + * + * @param int $dimensions The embedding dimensions. + * @return self + */ + public function usingDimensions(int $dimensions): self + { + $this->modelConfig->setDimensions($dimensions); + return $this; + } + /** + * Checks whether the specified model supports the current inputs and configuration. + * + * This reports whether the model set via {@see self::usingModel()} or + * {@see self::usingProviderModel()} can fulfill the request, rather than whether any available + * model can. + * + * Any reason the specified model cannot fulfill the request is reported as `false`, including an + * unregistered or unconfigured provider, a model ID the provider does not offer, and a provider + * that could not be reached. Only failing to specify a model at all is treated as a programming + * error and throws. + * + * A model instance provided via {@see self::usingModel()} is left untouched, so that checking + * for support does not alter it. Determining whether the model's provider is configured may + * require a request to the provider, which is cached for the remainder of the request. + * + * @since 1.4.0 + * + * @return bool True if the specified model supports embedding generation for the current + * inputs and configuration. + * @throws InvalidArgumentException If no model was specified. + */ + public function isSupported(): bool + { + if ($this->providerModel === null && $this->model === null) { + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + try { + $model = $this->locateModel(); + } catch (AiClientExceptionInterface $e) { + // The model is unusable: its provider is not registered or configured, the provider has + // no model with the given ID, or the provider could not be reached. Either way it cannot + // fulfill the request. + return \false; + } + if (!$model instanceof EmbeddingGenerationModelInterface) { + return \false; + } + return $this->describeUnmetRequirements($model) === null; + } + /** + * Generates an embedding result from the configured inputs. + * + * @since 1.4.0 + * + * @return EmbeddingResult The generated embedding result. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. + */ + public function generateEmbeddingResult(): EmbeddingResult + { + if ($this->inputs === []) { + throw new InvalidArgumentException('Cannot generate embeddings from empty input. Add content using withInput().'); + } + $capability = CapabilityEnum::embeddingGeneration(); + $model = $this->resolveModel(); + $this->dispatchEvent(new BeforeGenerateEmbeddingEvent($this->inputs, $model, $capability)); + $result = $model->generateEmbeddingResult($this->inputs); + // Embeddings map positionally to inputs, so the counts must match. + if (count($result->getEmbeddings()) !== count($this->inputs)) { + throw new RuntimeException(sprintf('Expected %d embedding(s) from the model, but received %d.', count($this->inputs), count($result->getEmbeddings()))); + } + $this->dispatchEvent(new AfterGenerateEmbeddingEvent($this->inputs, $model, $capability, $result)); + return $result; + } + /** + * Generates a single embedding from the configured input. + * + * @since 1.4.0 + * + * @return Embedding The generated embedding vector. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, the + * specified model cannot fulfill the request, or multiple + * inputs were provided. + */ + public function generateEmbedding(): Embedding + { + if (count($this->inputs) > 1) { + throw new InvalidArgumentException('generateEmbedding() returns a single vector; use generateEmbeddings() for multiple inputs.'); + } + return $this->generateEmbeddingResult()->getEmbedding(); + } + /** + * Generates embeddings from the configured inputs. + * + * @since 1.4.0 + * + * @return list The generated embedding vectors. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. + */ + public function generateEmbeddings(): array + { + return $this->generateEmbeddingResult()->getEmbeddings(); + } + /** + * Resolves the specified model and verifies it can fulfill the request. + * + * @since 1.5.0 + * + * @return ModelInterface&EmbeddingGenerationModelInterface The verified model. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model cannot fulfill the request. + */ + private function resolveModel(): ModelInterface + { + $model = $this->prepareModel(); + if (!$model instanceof EmbeddingGenerationModelInterface) { + throw new InvalidArgumentException(sprintf('Model "%s" from provider "%s" does not support embedding generation.', $model->metadata()->getId(), $model->providerMetadata()->getId())); + } + $unmetRequirements = $this->describeUnmetRequirements($model); + if ($unmetRequirements !== null) { + throw new InvalidArgumentException(sprintf('Model "%s" from provider "%s" cannot fulfill this embedding request. %s', $model->metadata()->getId(), $model->providerMetadata()->getId(), $unmetRequirements)); + } + return $model; + } + /** + * Locates the specified model, without preparing it for use. + * + * Unlike {@see self::prepareModel()}, this leaves a model instance provided by the caller + * untouched, so that it can be inspected without altering it. + * + * @since 1.5.0 + * + * @return ModelInterface The located model. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function locateModel(): ModelInterface + { + if ($this->providerModel !== null) { + [$providerIdOrClassName, $modelId] = $this->providerModel; + $this->assertProviderConfigured($providerIdOrClassName); + // Retrieving the model also binds its provider dependencies. + return $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); + } + if ($this->model !== null) { + $model = $this->model; + $this->assertProviderConfigured($model->providerMetadata()->getId()); + return $model; + } + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + /** + * Prepares the specified model for use, without verifying that it can fulfill the request. + * + * @since 1.5.0 + * + * @return ModelInterface The prepared model, with its dependencies and configuration bound. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function prepareModel(): ModelInterface + { + $model = $this->locateModel(); + // A model retrieved from the registry is already bound and configured, so only a model + // instance provided by the caller needs its dependencies and configuration bound here. + if ($model === $this->model) { + $model->setConfig($this->effectiveModelConfig($model)); + $this->registry->bindModelDependencies($model); + } + // Request options are only applicable to API-based models that make HTTP requests. + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + return $model; + } + /** + * Combines a model's own configuration with the configuration set on this builder. + * + * The builder's configuration takes precedence for any overlapping settings. Combining happens + * here, when the model is used, rather than when the model is set, so that the configuration of + * a model that was subsequently replaced is not applied to its replacement. + * + * @since 1.5.0 + * + * @param ModelInterface $model The model whose configuration to combine with the builder's. + * @return ModelConfig The effective configuration for the given model. + */ + private function effectiveModelConfig(ModelInterface $model): ModelConfig + { + return ModelConfig::fromArray(array_merge($model->getConfig()->toArray(), $this->modelConfig->toArray())); + } + /** + * Asserts that the given provider is configured and therefore usable. + * + * @since 1.5.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + * @throws InvalidArgumentException If the provider is not registered or not usable. + */ + private function assertProviderConfigured(string $providerIdOrClassName): void + { + // A provider reports itself unconfigured for any reason it cannot be used, including + // credentials that are missing, invalid, or rejected, so the message covers all of them. + if (!$this->registry->isProviderConfigured($providerIdOrClassName)) { + throw new InvalidArgumentException(sprintf('Provider "%s" is not registered, or is not configured with valid credentials. ' . 'Ensure the provider is registered and its credentials are present and valid ' . 'before generating embeddings.', $providerIdOrClassName)); + } + } + /** + * Describes the requirements of the current request that the given model does not meet. + * + * @since 1.5.0 + * + * @param ModelInterface $model The model to check. + * @return string|null A description of the unmet requirements, or null if the model meets them all. + */ + private function describeUnmetRequirements(ModelInterface $model): ?string + { + $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->effectiveModelConfig($model)); + /** @var UnmetModelRequirementsShape $unmetRequirements */ + $unmetRequirements = $requirements->getUnmetRequirements($model->metadata()); + $descriptions = []; + if ($unmetRequirements['capabilities'] !== []) { + $descriptions[] = sprintf('Unsupported capabilities: %s.', implode(', ', array_map(static fn(CapabilityEnum $capability): string => $capability->value, $unmetRequirements['capabilities']))); + } + if ($unmetRequirements['options'] !== []) { + $descriptions[] = sprintf('Unsupported options: %s.', implode(', ', array_map(fn(RequiredOption $option): string => sprintf('%s (%s)', $option->getName()->value, $this->describeOptionValue($option->getValue())), $unmetRequirements['options']))); + } + if ($descriptions === []) { + return null; + } + return implode(' ', $descriptions); + } + /** + * Describes an option value for use in an error message. + * + * @since 1.5.0 + * + * @param mixed $value The option value to describe. + * @return string The human readable description. + */ + private function describeOptionValue($value): string + { + if ($value === null) { + return 'null'; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if ($value instanceof AbstractEnum) { + return $value->value; + } + if (is_array($value)) { + return '[' . implode(', ', array_map(fn($item): string => $this->describeOptionValue($item), $value)) . ']'; + } + if (is_scalar($value)) { + return (string) $value; + } + return is_object($value) ? get_class($value) : gettype($value); + } + /** + * Parses a single input into a message part. + * + * @since 1.4.0 + * + * @param mixed $input The input to parse. Accepts a string, MessagePart, File, or + * MessagePartArrayShape. + * @return MessagePart The parsed message part. + * @throws InvalidArgumentException If the input type is not supported or is an invalid part type. + */ + private function parseInput($input): MessagePart + { + if ($input instanceof MessagePart) { + return $this->validatePart($input); + } + if (is_string($input)) { + if (trim($input) === '') { + throw new InvalidArgumentException('Cannot create an embedding input from an empty string.'); + } + return new MessagePart($input); + } + if ($input instanceof File) { + return new MessagePart($input); + } + if (is_array($input) && MessagePart::isArrayShape($input)) { + return $this->validatePart(MessagePart::fromArray($input)); + } + throw new InvalidArgumentException('Embedding input must be a string, MessagePart, File, or MessagePartArrayShape.'); + } + /** + * Validates that a message part is a valid embedding input. + * + * @since 1.4.0 + * + * @param MessagePart $part The part to validate. + * @return MessagePart The validated part. + * @throws InvalidArgumentException If the part is not a text or file part. + */ + private function validatePart(MessagePart $part): MessagePart + { + $type = $part->getType(); + if (!$type->isText() && !$type->isFile()) { + throw new InvalidArgumentException('Embedding inputs must be text or file parts.'); + } + return $part; + } + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since 1.4.0 + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php index 87b0fedd4f696..641d1915f1963 100644 --- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Builders; use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; @@ -16,11 +17,9 @@ use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface; -use WordPress\AiClient\Providers\Http\DTO\RequestOptions; +use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; @@ -49,34 +48,11 @@ */ class PromptBuilder { - /** - * @var ProviderRegistry The provider registry for finding suitable models. - */ - private ProviderRegistry $registry; + use ModelResolutionTrait; /** * @var list The messages in the conversation. */ protected array $messages = []; - /** - * @var ModelInterface|null The model to use for generation. - */ - protected ?ModelInterface $model = null; - /** - * @var list Ordered list of preference keys to check when selecting a model. - */ - protected array $modelPreferenceKeys = []; - /** - * @var string|null The provider ID or class name. - */ - protected ?string $providerIdOrClassName = null; - /** - * @var ModelConfig The model configuration. - */ - protected ModelConfig $modelConfig; - /** - * @var RequestOptions|null The request options for HTTP transport. - */ - protected ?RequestOptions $requestOptions = null; /** * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. */ @@ -94,8 +70,8 @@ class PromptBuilder // phpcs:enable Generic.Files.LineLength.TooLong public function __construct(ProviderRegistry $registry, $prompt = null, ?EventDispatcherInterface $eventDispatcher = null) { - $this->registry = $registry; $this->modelConfig = new ModelConfig(); + $this->modelResolver = new ModelResolver($registry); $this->eventDispatcher = $eventDispatcher; if ($prompt === null) { return; @@ -128,12 +104,10 @@ public function __clone() $this->messages = $clonedMessages; // Clone model config (ModelConfig has __clone) $this->modelConfig = clone $this->modelConfig; - // Clone request options if set (contains only primitives) - if ($this->requestOptions !== null) { - $this->requestOptions = clone $this->requestOptions; - } - // Note: $registry, $model, and $eventDispatcher are service objects - // and are intentionally NOT cloned - they should be shared references. + // Clone model resolver (ModelResolver has __clone; clones request options) + $this->modelResolver = clone $this->modelResolver; + // Note: $eventDispatcher is a service object and is intentionally NOT + // cloned - it should be a shared reference. } /** * Adds text to the current message. @@ -219,109 +193,6 @@ public function withHistory(Message ...$messages): self $this->messages = array_merge($messages, $this->messages); return $this; } - /** - * Sets the model to use for generation. - * - * The model's configuration will be merged with the builder's configuration, - * with the builder's configuration taking precedence for any overlapping settings. - * - * @since 0.1.0 - * - * @param ModelInterface $model The model to use. - * @return self - */ - public function usingModel(ModelInterface $model): self - { - $this->model = $model; - // Merge model's config with builder's config, with builder's config taking precedence - $modelConfigArray = $model->getConfig()->toArray(); - $builderConfigArray = $this->modelConfig->toArray(); - $mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray); - $this->modelConfig = ModelConfig::fromArray($mergedConfigArray); - return $this; - } - /** - * Sets preferred models to evaluate in order. - * - * @since 0.2.0 - * - * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, - * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify - * only model IDs or model instances, as that will allow for different providers that expose the same model to be - * considered. - * @return self - * - * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. - */ - public function usingModelPreference(...$preferredModels): self - { - if ($preferredModels === []) { - throw new InvalidArgumentException('At least one model preference must be provided.'); - } - $preferenceKeys = []; - foreach ($preferredModels as $preferredModel) { - if (is_array($preferredModel)) { - // [model identifier, provider ID] tuple - if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { - throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.'); - } - [$providerId, $modelId] = $preferredModel; - $modelId = $this->normalizePreferenceIdentifier($modelId); - $providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.'); - $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - } elseif ($preferredModel instanceof ModelInterface) { - // Model instance - $modelId = $preferredModel->metadata()->getId(); - $providerId = $preferredModel->providerMetadata()->getId(); - $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - } elseif (is_string($preferredModel)) { - // Model ID - $modelId = $this->normalizePreferenceIdentifier($preferredModel); - $preferenceKey = $this->createModelPreferenceKey($modelId); - } else { - // Invalid type - throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.'); - } - $preferenceKeys[] = $preferenceKey; - } - $this->modelPreferenceKeys = $preferenceKeys; - return $this; - } - /** - * Sets the model configuration. - * - * Merges the provided configuration with the builder's configuration, - * with builder configuration taking precedence. - * - * @since 0.1.0 - * - * @param ModelConfig $config The model configuration to merge. - * @return self - */ - public function usingModelConfig(ModelConfig $config): self - { - // Convert both configs to arrays - $builderConfigArray = $this->modelConfig->toArray(); - $providedConfigArray = $config->toArray(); - // Merge arrays with builder config taking precedence - $mergedArray = array_merge($providedConfigArray, $builderConfigArray); - // Create new config from merged array - $this->modelConfig = ModelConfig::fromArray($mergedArray); - return $this; - } - /** - * Sets the provider to use for generation. - * - * @since 0.1.0 - * - * @param string $providerIdOrClassName The provider ID or class name. - * @return self - */ - public function usingProvider(string $providerIdOrClassName): self - { - $this->providerIdOrClassName = $providerIdOrClassName; - return $this; - } /** * Sets the system instruction. * @@ -468,19 +339,6 @@ public function usingWebSearch(WebSearch $webSearch): self $this->modelConfig->setWebSearch($webSearch); return $this; } - /** - * Sets the request options for HTTP transport. - * - * @since 0.3.0 - * - * @param RequestOptions $requestOptions The request options. - * @return self - */ - public function usingRequestOptions(RequestOptions $requestOptions): self - { - $this->requestOptions = $requestOptions; - return $this; - } /** * Sets the top log probabilities configuration. * @@ -691,8 +549,9 @@ public function isSupported(?CapabilityEnum $capability = null): bool // If no intended capability provided, infer from output modalities if ($capability === null) { // First try to infer from a specific model if one is set - if ($this->model !== null) { - $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + $model = $this->modelResolver->getModel(); + if ($model !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($model); if ($inferredCapability !== null) { $capability = $inferredCapability; } @@ -704,18 +563,7 @@ public function isSupported(?CapabilityEnum $capability = null): bool } // Build requirements with the specified capability $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); - // If the model has been set, check if it meets the requirements - if ($this->model !== null) { - return $requirements->areMetBy($this->model->metadata()); - } - try { - // Check if any models support these requirements - $models = $this->registry->findModelsMetadataForSupport($requirements); - return !empty($models); - } catch (InvalidArgumentException $e) { - // No models support the requirements - return \false; - } + return $this->modelResolver->isSupported($requirements); } /** * Checks if the prompt is supported for text generation. @@ -815,8 +663,9 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi // If capability is not provided, infer it if ($capability === null) { // First try to infer from a specific model if one is set - if ($this->model !== null) { - $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + $setModel = $this->modelResolver->getModel(); + if ($setModel !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($setModel); if ($inferredCapability !== null) { $capability = $inferredCapability; } @@ -1148,155 +997,7 @@ protected function appendPartToMessages(MessagePart $part): void private function getConfiguredModel(CapabilityEnum $capability): ModelInterface { $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); - if ($this->model !== null) { - // Explicit model was provided via usingModel(); just update config and bind dependencies. - $model = $this->model; - $model->setConfig($this->modelConfig); - $this->registry->bindModelDependencies($model); - $this->bindModelRequestOptions($model); - return $model; - } - // Retrieve the candidate models map which satisfies the requirements. - $candidateMap = $this->getCandidateModelsMap($requirements); - if (empty($candidateMap)) { - $message = sprintf('No models found that support %s for this prompt.', $capability->value); - if ($this->providerIdOrClassName !== null) { - $message = sprintf('No models found for provider "%s" that support %s for this prompt.', $this->providerIdOrClassName, $capability->value); - } - throw new InvalidArgumentException($message); - } - // Check if any preferred models match the candidates, in priority order. - if (!empty($this->modelPreferenceKeys)) { - // Find preferences that match available candidates, preserving preference order. - $matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap); - if (!empty($matchingPreferences)) { - // Get the first matching preference key - $firstMatchKey = key($matchingPreferences); - [$providerId, $modelId] = $candidateMap[$firstMatchKey]; - $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); - $this->bindModelRequestOptions($model); - return $model; - } - } - // No preference matched; fall back to the first candidate discovered. - [$providerId, $modelId] = reset($candidateMap); - $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); - $this->bindModelRequestOptions($model); - return $model; - } - /** - * Binds configured request options to the model if present and supported. - * - * Request options are only applicable to API-based models that make HTTP requests. - * - * @since 0.3.0 - * - * @param ModelInterface $model The model to bind request options to. - * @return void - */ - private function bindModelRequestOptions(ModelInterface $model): void - { - if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { - $model->setRequestOptions($this->requestOptions); - } - } - /** - * Builds a map of candidate models that satisfy the requirements for efficient lookup. - * - * @since 0.2.0 - * - * @param ModelRequirements $requirements The requirements derived from the prompt. - * @return array Map of preference keys to [providerId, modelId] tuples. - */ - private function getCandidateModelsMap(ModelRequirements $requirements): array - { - if ($this->providerIdOrClassName === null) { - // No provider locked in, gather all models across providers that meet requirements. - $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); - $candidateMap = []; - foreach ($providerModelsMetadata as $providerModels) { - $providerId = $providerModels->getProvider()->getId(); - $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); - // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) - $candidateMap = $candidateMap + $providerMap; - } - return $candidateMap; - } - // Provider set, only consider models from that provider. - $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements); - // Ensure we pass the provider ID, not the class name - $providerId = $this->registry->getProviderId($this->providerIdOrClassName); - return $this->generateMapFromCandidates($providerId, $modelsMetadata); - } - /** - * Generates a candidate map from model metadata with both provider-specific and model-only keys. - * - * @since 0.2.0 - * - * @param string $providerId The provider ID. - * @param list $modelsMetadata The models metadata to map. - * @return array Map of preference keys to [providerId, modelId] tuples. - */ - private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array - { - $map = []; - foreach ($modelsMetadata as $modelMetadata) { - $modelId = $modelMetadata->getId(); - // Add provider-specific key - $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); - $map[$providerModelKey] = [$providerId, $modelId]; - // Add model-only key - $modelKey = $this->createModelPreferenceKey($modelId); - $map[$modelKey] = [$providerId, $modelId]; - } - return $map; - } - /** - * Normalizes and validates a preference identifier string. - * - * @since 0.2.0 - * - * @param mixed $value The value to normalize. - * @param string $emptyMessage The message for empty or invalid values. - * @return string The normalized identifier. - * - * @throws InvalidArgumentException If the value is not a non-empty string. - */ - private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string - { - if (!is_string($value)) { - throw new InvalidArgumentException($emptyMessage); - } - $trimmed = trim($value); - if ($trimmed === '') { - throw new InvalidArgumentException($emptyMessage); - } - return $trimmed; - } - /** - * Creates a preference key for a provider/model combination. - * - * @since 0.2.0 - * - * @param string $providerId The provider identifier. - * @param string $modelId The model identifier. - * @return string The generated preference key. - */ - private function createProviderModelPreferenceKey(string $providerId, string $modelId): string - { - return 'providerModel::' . $providerId . '::' . $modelId; - } - /** - * Creates a preference key for a model identifier. - * - * @since 0.2.0 - * - * @param string $modelId The model identifier. - * @return string The generated preference key. - */ - private function createModelPreferenceKey(string $modelId): string - { - return 'model::' . $modelId; + return $this->modelResolver->resolve($requirements, $this->modelConfig, 'prompt'); } /** * Parses various input types into a Message with the given role. @@ -1374,14 +1075,16 @@ private function parseMessage($input, MessageRoleEnum $defaultRole): Message */ private function validateMessages(): void { - if (empty($this->messages)) { + // Work on a copy so reset() and end() do not change the property array's internal pointer. + $messages = $this->messages; + if (empty($messages)) { throw new InvalidArgumentException('Cannot generate from an empty prompt. Add content using withText() or similar methods.'); } - $firstMessage = reset($this->messages); + $firstMessage = reset($messages); if (!$firstMessage->getRole()->isUser()) { throw new InvalidArgumentException('The first message must be from a user role, not from ' . $firstMessage->getRole()->value); } - $lastMessage = end($this->messages); + $lastMessage = end($messages); if (!$lastMessage->getRole()->isUser()) { throw new InvalidArgumentException('The last message must be from a user role, not from ' . $lastMessage->getRole()->value); } diff --git a/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php new file mode 100644 index 0000000000000..1718a81f222cf --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelConfigurationTrait.php @@ -0,0 +1,56 @@ +mergeModelConfig($config); + return $this; + } + /** + * Merges the given configuration into the builder's configuration. + * + * The builder's own configuration takes precedence for any overlapping settings, so that + * explicitly configured values are never overwritten by defaults from another source. + * + * @since 1.5.0 + * + * @param ModelConfig $config The model configuration to merge. + * @return void + */ + protected function mergeModelConfig(ModelConfig $config): void + { + // Merge arrays with builder config taking precedence + $mergedArray = array_merge($config->toArray(), $this->modelConfig->toArray()); + // Create new config from merged array + $this->modelConfig = ModelConfig::fromArray($mergedArray); + } +} diff --git a/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php new file mode 100644 index 0000000000000..a04abcb23c9fa --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/Traits/ModelResolutionTrait.php @@ -0,0 +1,92 @@ +modelResolver->setModel($model); + // Merge model's config with builder's config, with builder's config taking precedence + $this->mergeModelConfig($model->getConfig()); + return $this; + } + /** + * Sets preferred models to evaluate in order. + * + * @since 0.2.0 + * + * @param string|ModelInterface|ProviderModelTuple ...$preferredModels The preferred models as model IDs, + * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify + * only model IDs or model instances, as that will allow for different providers that expose the same model to be + * considered. + * @return self + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function usingModelPreference(...$preferredModels): self + { + $this->modelResolver->setModelPreferences(...$preferredModels); + return $this; + } + /** + * Sets the provider to use for generation. + * + * @since 0.1.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return self + */ + public function usingProvider(string $providerIdOrClassName): self + { + $this->modelResolver->setProvider($providerIdOrClassName); + return $this; + } + /** + * Sets the request options for HTTP transport. + * + * @since 0.3.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->modelResolver->setRequestOptions($requestOptions); + return $this; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php b/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php new file mode 100644 index 0000000000000..d8a4644cecd48 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/AfterGenerateEmbeddingEvent.php @@ -0,0 +1,108 @@ + The inputs that were sent to the model. + */ + private array $inputs; + /** + * @var ModelInterface The model that generated embeddings. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum The capability that was used for generation. + */ + private CapabilityEnum $capability; + /** + * @var EmbeddingResult The result from the model. + */ + private EmbeddingResult $result; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param list $inputs The inputs that were sent to the model. + * @param ModelInterface $model The model that generated embeddings. + * @param CapabilityEnum $capability The capability that was used for generation. + * @param EmbeddingResult $result The result from the model. + */ + public function __construct(array $inputs, ModelInterface $model, CapabilityEnum $capability, EmbeddingResult $result) + { + $this->inputs = $inputs; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + /** + * Gets the inputs that were sent to the model. + * + * @since 1.4.0 + * + * @return list The inputs. + */ + public function getInputs(): array + { + return $this->inputs; + } + /** + * Gets the model that generated embeddings. + * + * @since 1.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability that was used for generation. + * + * @since 1.4.0 + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + /** + * Gets the result from the model. + * + * @since 1.4.0 + * + * @return EmbeddingResult The result. + */ + public function getResult(): EmbeddingResult + { + return $this->result; + } + /** + * Performs a deep clone of the event. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $this->inputs = $clonedInputs; + $this->result = clone $this->result; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php new file mode 100644 index 0000000000000..9a6a6b4df06ee --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateEmbeddingEvent.php @@ -0,0 +1,89 @@ + The inputs to be sent to the model. + */ + private array $inputs; + /** + * @var ModelInterface The model that will generate embeddings. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum The capability being used for generation. + */ + private CapabilityEnum $capability; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param list $inputs The inputs to be sent to the model. + * @param ModelInterface $model The model that will generate embeddings. + * @param CapabilityEnum $capability The capability being used for generation. + */ + public function __construct(array $inputs, ModelInterface $model, CapabilityEnum $capability) + { + $this->inputs = $inputs; + $this->model = $model; + $this->capability = $capability; + } + /** + * Gets the inputs to be sent to the model. + * + * @since 1.4.0 + * + * @return list The inputs. + */ + public function getInputs(): array + { + return $this->inputs; + } + /** + * Gets the model that will generate embeddings. + * + * @since 1.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability being used for generation. + * + * @since 1.4.0 + * + * @return CapabilityEnum The capability. + */ + public function getCapability(): CapabilityEnum + { + return $this->capability; + } + /** + * Performs a deep clone of the event. + * + * @since 1.4.0 + */ + public function __clone() + { + $clonedInputs = []; + foreach ($this->inputs as $input) { + $clonedInputs[] = clone $input; + } + $this->inputs = $clonedInputs; + } +} diff --git a/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php b/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php index 21ea89364d24d..6cb7f05d99164 100644 --- a/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php +++ b/src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php @@ -5,7 +5,7 @@ use WordPress\AiClient\Common\AbstractEnum; /** - * Represents the type of file storage. + * Represents the media orientation. * * @method static self square() Returns the square orientation * @method static self landscape() Returns the landscape orientation. diff --git a/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php b/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php new file mode 100644 index 0000000000000..a4bfec4cdb077 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/Util/ModalityCombinationsUtil.php @@ -0,0 +1,48 @@ + $required Required modalities included in every combination. + * @param list $optional Optional modalities to generate subsets from. + * @return list> List of modality combinations, each containing + * all required modalities plus a unique subset of + * the optional modalities. + */ + public static function buildCombinations(array $required, array $optional): array + { + $combinations = []; + $count = count($optional); + $subsetCount = 1 << $count; + // 2^count. + for ($i = 0; $i < $subsetCount; $i++) { + $combo = $required; + for ($j = 0; $j < $count; $j++) { + if ($i & 1 << $j) { + $combo[] = $optional[$j]; + } + } + $combinations[] = $combo; + } + return $combinations; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 4f7e2a338fabc..2989f326d6676 100644 --- a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -31,6 +31,14 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi * @var string */ private const MODELS_CACHE_KEY = 'models'; + /** + * Request-local cache for explicit model metadata lookups. + * + * @since 1.5.0 + * + * @var array + */ + private array $explicitModelMetadataCache = []; /** * {@inheritDoc} * @@ -39,7 +47,8 @@ abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDi final public function listModelMetadata(): array { $modelsMetadata = $this->getModelMetadataMap(); - return array_values($modelsMetadata); + $explicitModelsMetadata = $this->getExplicitModelMetadataMap(array_keys($modelsMetadata)); + return array_values(array_replace($modelsMetadata, $explicitModelsMetadata)); } /** * {@inheritDoc} @@ -48,6 +57,9 @@ final public function listModelMetadata(): array */ final public function hasModelMetadata(string $modelId): bool { + if (isset($this->getExplicitModelMetadataMap([$modelId])[$modelId])) { + return \true; + } $modelsMetadata = $this->getModelMetadataMap(); return isset($modelsMetadata[$modelId]); } @@ -58,12 +70,40 @@ final public function hasModelMetadata(string $modelId): bool */ final public function getModelMetadata(string $modelId): ModelMetadata { + $explicitModelsMetadata = $this->getExplicitModelMetadataMap([$modelId]); + if (isset($explicitModelsMetadata[$modelId])) { + return $explicitModelsMetadata[$modelId]; + } $modelsMetadata = $this->getModelMetadataMap(); if (!isset($modelsMetadata[$modelId])) { throw new InvalidArgumentException(sprintf('No model with ID %s was found in the provider', $modelId)); } return $modelsMetadata[$modelId]; } + /** + * Gets explicit model metadata using request-local memoization. + * + * @since 1.5.0 + * + * @param list $modelIds Model IDs for which to get explicit metadata. + * @return array Map of model ID to explicit model metadata. + */ + private function getExplicitModelMetadataMap(array $modelIds): array + { + $uncheckedModelIds = []; + foreach ($modelIds as $modelId) { + if (!array_key_exists($modelId, $this->explicitModelMetadataCache)) { + $uncheckedModelIds[] = $modelId; + } + } + if ($uncheckedModelIds) { + $explicitModelsMetadata = $this->createModelMetadataForExplicitModelIds($uncheckedModelIds); + foreach ($uncheckedModelIds as $modelId) { + $this->explicitModelMetadataCache[$modelId] = $explicitModelsMetadata[$modelId] ?? null; + } + } + return array_filter(array_intersect_key($this->explicitModelMetadataCache, array_flip($modelIds))); + } /** * Returns the map of model ID to model metadata for all models from the provider. * @@ -94,6 +134,22 @@ protected function getBaseCacheKey(): string { return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class); } + /** + * Creates metadata for explicit model IDs without listing provider models. + * + * Providers whose APIs accept arbitrary/current model IDs can override this to avoid a live list-models request + * when callers already know the model ID they want to instantiate. + * + * @since 1.5.0 + * + * @param list $modelIds The explicit model IDs. + * @return array Map of model ID to model metadata. Omit IDs that should fall back to the + * provider models list. + */ + protected function createModelMetadataForExplicitModelIds(array $modelIds): array + { + return []; + } /** * Sends the API request to list models from the provider and returns the map of model ID to model metadata. * diff --git a/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php new file mode 100644 index 0000000000000..975a2bb3d7694 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ModelResolver.php @@ -0,0 +1,378 @@ + Ordered list of preference keys to check when selecting a model. + */ + private array $modelPreferenceKeys = []; + /** + * @var string|null The provider ID or class name. + */ + private ?string $providerIdOrClassName = null; + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + private ?RequestOptions $requestOptions = null; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + */ + public function __construct(\WordPress\AiClient\Providers\ProviderRegistry $registry) + { + $this->registry = $registry; + } + /** + * Creates a deep clone of this resolver. + * + * Clones the request options (which contain only primitives). The registry and + * explicitly set model are service objects and are intentionally NOT cloned, as + * they should be shared references. + * + * @since 1.4.0 + */ + public function __clone() + { + if ($this->requestOptions !== null) { + $this->requestOptions = clone $this->requestOptions; + } + } + /** + * Sets the model to use for generation. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to use. + * @return void + */ + public function setModel(ModelInterface $model): void + { + $this->model = $model; + } + /** + * Gets the explicitly set model, if any. + * + * @since 1.4.0 + * + * @return ModelInterface|null The explicitly set model, or null if none was set. + */ + public function getModel(): ?ModelInterface + { + return $this->model; + } + /** + * Sets preferred models to evaluate in order. + * + * @since 1.4.0 + * + * @param string|ModelInterface|ProviderModelTuple ...$preferredModels The preferred models as model IDs, + * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify + * only model IDs or model instances, as that will allow for different providers that expose the same model to be + * considered. + * @return void + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function setModelPreferences(...$preferredModels): void + { + if ($preferredModels === []) { + throw new InvalidArgumentException('At least one model preference must be provided.'); + } + $preferenceKeys = []; + foreach ($preferredModels as $preferredModel) { + if (is_array($preferredModel)) { + // [model identifier, provider ID] tuple + if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { + throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.'); + } + [$providerId, $modelId] = $preferredModel; + $modelId = $this->normalizePreferenceIdentifier($modelId); + $providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.'); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif ($preferredModel instanceof ModelInterface) { + // Model instance + $modelId = $preferredModel->metadata()->getId(); + $providerId = $preferredModel->providerMetadata()->getId(); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif (is_string($preferredModel)) { + // Model ID + $modelId = $this->normalizePreferenceIdentifier($preferredModel); + $preferenceKey = $this->createModelPreferenceKey($modelId); + } else { + // Invalid type + throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.'); + } + $preferenceKeys[] = $preferenceKey; + } + $this->modelPreferenceKeys = $preferenceKeys; + } + /** + * Sets the provider to use for generation. + * + * @since 1.4.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + */ + public function setProvider(string $providerIdOrClassName): void + { + $this->providerIdOrClassName = $providerIdOrClassName; + } + /** + * Sets the request options for HTTP transport. + * + * @since 1.4.0 + * + * @param RequestOptions $requestOptions The request options. + * @return void + */ + public function setRequestOptions(RequestOptions $requestOptions): void + { + $this->requestOptions = $requestOptions; + } + /** + * Resolves the model to use for the given requirements. + * + * If a model has been explicitly set, updates its config, binds dependencies, + * and returns it. Otherwise, finds a suitable model based on the requirements, + * honoring any configured model preferences and provider constraint. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements the model must satisfy. + * @param ModelConfig $modelConfig The model configuration to apply. + * @param string|null $subject Optional caller-specific subject for failure messages. + * @return ModelInterface The model to use. + * @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements. + */ + public function resolve(ModelRequirements $requirements, ModelConfig $modelConfig, ?string $subject = null): ModelInterface + { + if ($this->model !== null) { + // Explicit model was provided via setModel(); just update config and bind dependencies. + $model = $this->model; + $model->setConfig($modelConfig); + $this->registry->bindModelDependencies($model); + $this->bindModelRequestOptions($model); + return $model; + } + // Retrieve the candidate models map which satisfies the requirements. + $candidateMap = $this->getCandidateModelsMap($requirements); + if (empty($candidateMap)) { + // The primary capability is always the first required capability (see + // ModelRequirements::fromPromptData()/fromEmbeddingData()). + $requiredCapabilities = $requirements->getRequiredCapabilities(); + $primaryCapability = reset($requiredCapabilities); + if ($primaryCapability === \false) { + $message = 'No models found that meet the requested requirements.'; + if ($this->providerIdOrClassName !== null) { + $message = sprintf('No models found for provider "%s" that meet the requested requirements.', $this->providerIdOrClassName); + } + throw new InvalidArgumentException($message); + } + $capabilityValue = $primaryCapability->value; + $message = sprintf('No models found that support %s.', $capabilityValue); + if ($subject !== null) { + $message = sprintf('No models found that support %s for this %s.', $capabilityValue, $subject); + } + if ($this->providerIdOrClassName !== null) { + $message = sprintf('No models found for provider "%s" that support %s.', $this->providerIdOrClassName, $capabilityValue); + if ($subject !== null) { + $message = sprintf('No models found for provider "%s" that support %s for this %s.', $this->providerIdOrClassName, $capabilityValue, $subject); + } + } + throw new InvalidArgumentException($message); + } + // Check if any preferred models match the candidates, in priority order. + if (!empty($this->modelPreferenceKeys)) { + // Find preferences that match available candidates, preserving preference order. + $matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap); + if (!empty($matchingPreferences)) { + // Get the first matching preference key + $firstMatchKey = key($matchingPreferences); + [$providerId, $modelId] = $candidateMap[$firstMatchKey]; + $model = $this->registry->getProviderModel($providerId, $modelId, $modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + } + // No preference matched; fall back to the first candidate discovered. + [$providerId, $modelId] = reset($candidateMap); + $model = $this->registry->getProviderModel($providerId, $modelId, $modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + /** + * Checks whether any model can satisfy the given requirements. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements to check support for. + * @return bool True if the set model or any registered model meets the requirements. + */ + public function isSupported(ModelRequirements $requirements): bool + { + // If the model has been set, check if it meets the requirements + if ($this->model !== null) { + return $requirements->areMetBy($this->model->metadata()); + } + try { + // Check if any models support these requirements + $models = $this->registry->findModelsMetadataForSupport($requirements); + return !empty($models); + } catch (InvalidArgumentException $e) { + // No models support the requirements + return \false; + } + } + /** + * Binds configured request options to the model if present and supported. + * + * Request options are only applicable to API-based models that make HTTP requests. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to bind request options to. + * @return void + */ + private function bindModelRequestOptions(ModelInterface $model): void + { + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + } + /** + * Builds a map of candidate models that satisfy the requirements for efficient lookup. + * + * @since 1.4.0 + * + * @param ModelRequirements $requirements The requirements derived from the prompt. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function getCandidateModelsMap(ModelRequirements $requirements): array + { + if ($this->providerIdOrClassName === null) { + // No provider locked in, gather all models across providers that meet requirements. + $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + $candidateMap = []; + foreach ($providerModelsMetadata as $providerModels) { + $providerId = $providerModels->getProvider()->getId(); + $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); + // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) + $candidateMap = $candidateMap + $providerMap; + } + return $candidateMap; + } + // Provider set, only consider models from that provider. + $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements); + // Ensure we pass the provider ID, not the class name + $providerId = $this->registry->getProviderId($this->providerIdOrClassName); + return $this->generateMapFromCandidates($providerId, $modelsMetadata); + } + /** + * Generates a candidate map from model metadata with both provider-specific and model-only keys. + * + * @since 1.4.0 + * + * @param string $providerId The provider ID. + * @param list $modelsMetadata The models metadata to map. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array + { + $map = []; + foreach ($modelsMetadata as $modelMetadata) { + $modelId = $modelMetadata->getId(); + // Add provider-specific key + $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + $map[$providerModelKey] = [$providerId, $modelId]; + // Add model-only key + $modelKey = $this->createModelPreferenceKey($modelId); + $map[$modelKey] = [$providerId, $modelId]; + } + return $map; + } + /** + * Normalizes and validates a preference identifier string. + * + * @since 1.4.0 + * + * @param mixed $value The value to normalize. + * @param string $emptyMessage The message for empty or invalid values. + * @return string The normalized identifier. + * + * @throws InvalidArgumentException If the value is not a non-empty string. + */ + private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string + { + if (!is_string($value)) { + throw new InvalidArgumentException($emptyMessage); + } + $trimmed = trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException($emptyMessage); + } + return $trimmed; + } + /** + * Creates a preference key for a provider/model combination. + * + * @since 1.4.0 + * + * @param string $providerId The provider identifier. + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createProviderModelPreferenceKey(string $providerId, string $modelId): string + { + return 'providerModel::' . $providerId . '::' . $modelId; + } + /** + * Creates a preference key for a model identifier. + * + * @since 1.4.0 + * + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createModelPreferenceKey(string $modelId): string + { + return 'model::' . $modelId; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php index 00f1c69e008dc..fa465dbdf48eb 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php @@ -43,6 +43,7 @@ * outputMediaOrientation?: string, * outputMediaAspectRatio?: string, * outputSpeechVoice?: string, + * dimensions?: int, * customOptions?: array * } * @@ -70,6 +71,7 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation'; public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; + public const KEY_DIMENSIONS = 'dimensions'; public const KEY_CUSTOM_OPTIONS = 'customOptions'; /* * Note: This key is not an actual model config key, but specified here for convenience. @@ -157,6 +159,10 @@ class ModelConfig extends AbstractDataTransferObject * @var string|null Output speech voice. */ protected ?string $outputSpeechVoice = null; + /** + * @var int|null Embedding vector dimensions. + */ + protected ?int $dimensions = null; /** * @var array Custom provider-specific options. */ @@ -682,6 +688,31 @@ public function getOutputSpeechVoice(): ?string { return $this->outputSpeechVoice; } + /** + * Sets the embedding dimensions. + * + * @since 1.4.0 + * + * @param int $dimensions The embedding dimensions. + */ + public function setDimensions(int $dimensions): void + { + if ($dimensions < 1) { + throw new InvalidArgumentException('Dimensions must be greater than zero.'); + } + $this->dimensions = $dimensions; + } + /** + * Gets the embedding dimensions. + * + * @since 1.4.0 + * + * @return int|null The embedding dimensions. + */ + public function getDimensions(): ?int + { + return $this->dimensions; + } /** * Sets a single custom option. * @@ -723,7 +754,7 @@ public function getCustomOptions(): array */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false]; + return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_DIMENSIONS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Embedding vector dimensions.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false]; } /** * {@inheritDoc} @@ -799,6 +830,9 @@ public function toArray(): array if ($this->outputSpeechVoice !== null) { $data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice; } + if ($this->dimensions !== null) { + $data[self::KEY_DIMENSIONS] = $this->dimensions; + } if (!empty($this->customOptions)) { $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; } @@ -874,6 +908,9 @@ public static function fromArray(array $array): self if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) { $config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]); } + if (isset($array[self::KEY_DIMENSIONS])) { + $config->setDimensions($array[self::KEY_DIMENSIONS]); + } if (isset($array[self::KEY_CUSTOM_OPTIONS])) { $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); } diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php index 54d0f7cb37dcc..3d45c525f5b9b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; +use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; @@ -24,6 +25,11 @@ * requiredOptions: list * } * + * @phpstan-type UnmetModelRequirementsShape array{ + * capabilities: list, + * options: list + * } + * * @extends AbstractDataTransferObject */ class ModelRequirements extends AbstractDataTransferObject @@ -90,6 +96,23 @@ public function getRequiredOptions(): array * @return bool True if the model meets all requirements, false otherwise. */ public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): bool + { + $unmetRequirements = $this->getUnmetRequirements($metadata); + return $unmetRequirements['capabilities'] === [] && $unmetRequirements['options'] === []; + } + /** + * Determines which of these requirements the given model metadata does not meet. + * + * Unlike {@see self::areMetBy()}, this method reports the specific capabilities and options that + * are unsupported, so that calling code can explain why a model is unsuitable. + * + * @since 1.5.0 + * + * @param ModelMetadata $metadata The model metadata to check against. + * @return UnmetModelRequirementsShape The unsupported capabilities and options. Both lists are empty if the + * model meets all requirements. + */ + public function getUnmetRequirements(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): array { // Create lookup maps for better performance (instead of nested foreach loops) $capabilitiesMap = []; @@ -100,25 +123,23 @@ public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata foreach ($metadata->getSupportedOptions() as $option) { $optionsMap[$option->getName()->value] = $option; } - // Check if all required capabilities are supported using map lookup + // Collect required capabilities that are not supported, using map lookup + $unmetCapabilities = []; foreach ($this->requiredCapabilities as $requiredCapability) { if (!isset($capabilitiesMap[$requiredCapability->value])) { - return \false; + $unmetCapabilities[] = $requiredCapability; } } - // Check if all required options are supported with the specified values + // Collect required options that are either unsupported or unsupported with the required value + $unmetOptions = []; foreach ($this->requiredOptions as $requiredOption) { // Use map lookup instead of linear search - if (!isset($optionsMap[$requiredOption->getName()->value])) { - return \false; - } - $supportedOption = $optionsMap[$requiredOption->getName()->value]; - // Check if the required value is supported by this option - if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { - return \false; + $supportedOption = $optionsMap[$requiredOption->getName()->value] ?? null; + if ($supportedOption === null || !$supportedOption->isSupportedValue($requiredOption->getValue())) { + $unmetOptions[] = $requiredOption; } } - return \true; + return ['capabilities' => $unmetCapabilities, 'options' => $unmetOptions]; } /** * Creates ModelRequirements from prompt data and model configuration. @@ -143,24 +164,9 @@ public static function fromPromptData(CapabilityEnum $capability, array $message $hasFunctionMessageParts = \false; foreach ($messages as $message) { foreach ($message->getParts() as $part) { - // Check for text input - if ($part->getType()->isText()) { - $inputModalities[] = ModalityEnum::text(); - } - // Check for file inputs - if ($part->getType()->isFile()) { - $file = $part->getFile(); - if ($file !== null) { - if ($file->isImage()) { - $inputModalities[] = ModalityEnum::image(); - } elseif ($file->isAudio()) { - $inputModalities[] = ModalityEnum::audio(); - } elseif ($file->isVideo()) { - $inputModalities[] = ModalityEnum::video(); - } elseif ($file->isDocument() || $file->isText()) { - $inputModalities[] = ModalityEnum::document(); - } - } + $modality = self::inputModalityForPart($part); + if ($modality !== null) { + $inputModalities[] = $modality; } // Check for function calls/responses (these might require special capabilities) if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) { @@ -183,6 +189,72 @@ public static function fromPromptData(CapabilityEnum $capability, array $message // Step 6: Return new ModelRequirements return new self($capabilities, $requiredOptions); } + /** + * Creates ModelRequirements from embedding input data and model configuration. + * + * Unlike {@see self::fromPromptData()}, embedding inputs are independent items rather than a + * conversation, so no chat history capability is inferred. Each input contributes its input + * modality (text or file) to the requirements. + * + * @since 1.4.0 + * + * @param list $inputs The embedding inputs. + * @param ModelConfig $modelConfig The model configuration. + * @return self The created requirements. + */ + public static function fromEmbeddingData(array $inputs, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self + { + $capabilities = [CapabilityEnum::embeddingGeneration()]; + $inputModalities = []; + // Analyze each input to determine required input modalities. Function call/response parts + // are not valid embedding inputs and are rejected before reaching this point. + foreach ($inputs as $part) { + $modality = self::inputModalityForPart($part); + if ($modality !== null) { + $inputModalities[] = $modality; + } + } + // Convert ModelConfig to RequiredOptions + $requiredOptions = self::toRequiredOptions($modelConfig); + // Add input modalities if we have any inputs + if (!empty($inputModalities)) { + // Remove duplicates + $inputModalities = array_unique($inputModalities, \SORT_REGULAR); + $requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities))); + } + return new self($capabilities, $requiredOptions); + } + /** + * Determines the input modality contributed by a message part, if any. + * + * @since 1.4.0 + * + * @param MessagePart $part The message part to analyze. + * @return ModalityEnum|null The input modality, or null if the part contributes none. + */ + private static function inputModalityForPart(MessagePart $part): ?ModalityEnum + { + // Check for text input + if ($part->getType()->isText()) { + return ModalityEnum::text(); + } + // Check for file inputs + if ($part->getType()->isFile()) { + $file = $part->getFile(); + if ($file !== null) { + if ($file->isImage()) { + return ModalityEnum::image(); + } elseif ($file->isAudio()) { + return ModalityEnum::audio(); + } elseif ($file->isVideo()) { + return ModalityEnum::video(); + } elseif ($file->isDocument() || $file->isText()) { + return ModalityEnum::document(); + } + } + } + return null; + } /** * Converts ModelConfig to an array of RequiredOptions. * @@ -253,6 +325,9 @@ private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\D if ($modelConfig->getOutputMediaAspectRatio() !== null) { $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio()); } + if ($modelConfig->getDimensions() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::dimensions(), $modelConfig->getDimensions()); + } // Add custom options as individual RequiredOptions foreach ($modelConfig->getCustomOptions() as $key => $value) { $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]); diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php new file mode 100644 index 0000000000000..e7155a20097cb --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php @@ -0,0 +1,24 @@ + $inputs The inputs to embed, one embedding generated per input. + * @return EmbeddingResult Result containing one embedding per input, in input order. + */ + public function generateEmbeddingResult(array $inputs): EmbeddingResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php index c0d089153cf07..6fb78a69ee08b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php +++ b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php @@ -19,6 +19,7 @@ * Dynamically loaded from ModelConfig KEY_* constants: * @method static self candidateCount() Creates an instance for CANDIDATE_COUNT option. * @method static self customOptions() Creates an instance for CUSTOM_OPTIONS option. + * @method static self dimensions() Creates an instance for DIMENSIONS option. * @method static self frequencyPenalty() Creates an instance for FREQUENCY_PENALTY option. * @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS option. * @method static self logprobs() Creates an instance for LOGPROBS option. @@ -40,6 +41,7 @@ * @method static self webSearch() Creates an instance for WEB_SEARCH option. * @method bool isCandidateCount() Checks if the option is CANDIDATE_COUNT. * @method bool isCustomOptions() Checks if the option is CUSTOM_OPTIONS. + * @method bool isDimensions() Checks if the option is DIMENSIONS. * @method bool isFrequencyPenalty() Checks if the option is FREQUENCY_PENALTY. * @method bool isFunctionDeclarations() Checks if the option is FUNCTION_DECLARATIONS. * @method bool isLogprobs() Checks if the option is LOGPROBS. diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php b/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php new file mode 100644 index 0000000000000..ecd02a013b2a8 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/Embedding.php @@ -0,0 +1,168 @@ + + * + * @implements IteratorAggregate + */ +final class Embedding implements Countable, IteratorAggregate, JsonSerializable +{ + /** + * @var EmbeddingList + */ + private array $values; + /** + * @var int The vector dimension count. + */ + private int $dimensions; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param EmbeddingList $values The embedding vector values. + * @param int $dimensions The vector dimension count. + */ + public function __construct(array $values, int $dimensions) + { + if ($dimensions < 1) { + throw new InvalidArgumentException('Embedding dimensions must be greater than zero'); + } + if (!self::isEmbeddingList($values)) { + if (!array_is_list($values)) { + throw new InvalidArgumentException('Embedding values must be a list array.'); + } + throw new InvalidArgumentException('Embedding values must be integers or floats.'); + } + if (count($values) !== $dimensions) { + throw new InvalidArgumentException('Embedding vector length must match dimensions.'); + } + $this->values = $values; + $this->dimensions = $dimensions; + } + /** + * Checks whether the value is a list of integers or floats. + * + * @since 1.4.0 + * + * @param mixed $values The value to check. + * @return bool True if the value is an embedding list. + * + * @phpstan-assert-if-true EmbeddingList $values + */ + private static function isEmbeddingList($values): bool + { + if (!is_array($values) || !array_is_list($values)) { + return \false; + } + foreach ($values as $value) { + if (!is_int($value) && !is_float($value)) { + return \false; + } + } + return \true; + } + /** + * Gets the vector values. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function getValues(): array + { + return $this->values; + } + /** + * Gets the vector dimension count. + * + * @since 1.4.0 + * + * @return int The vector dimension count. + */ + public function getDimensions(): int + { + return $this->dimensions; + } + /** + * Gets the number of vector values. + * + * @since 1.4.0 + * + * @return int The number of vector values. + */ + public function count(): int + { + return $this->dimensions; + } + /** + * Gets an iterator for the vector values. + * + * @since 1.4.0 + * + * @return Traversable The vector value iterator. + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->values); + } + /** + * Gets the JSON schema for embedding vectors. + * + * @since 1.4.0 + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return ['type' => 'array', 'items' => ['type' => 'number'], 'description' => 'Generated embedding vector values.']; + } + /** + * Converts the embedding to an array. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function toArray(): array + { + return $this->values; + } + /** + * Creates an embedding from an array. + * + * @since 1.4.0 + * + * @param EmbeddingList $array The embedding vector values. + * @param int $dimensions The vector dimension count. + * @return self The embedding instance. + */ + public static function fromArray(array $array, int $dimensions): self + { + return new self($array, $dimensions); + } + /** + * Converts the embedding to a JSON-serializable value. + * + * @since 1.4.0 + * + * @return EmbeddingList The embedding vector values. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php b/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php new file mode 100644 index 0000000000000..f914fd80a0a8f --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/EmbeddingResult.php @@ -0,0 +1,225 @@ +, + * dimensions: int, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata: ProviderMetadataArrayShape, + * modelMetadata: ModelMetadataArrayShape, + * additionalData?: array + * } + * + * @extends AbstractDataTransferObject + */ +class EmbeddingResult extends AbstractDataTransferObject implements ResultInterface +{ + public const KEY_ID = 'id'; + public const KEY_EMBEDDINGS = 'embeddings'; + public const KEY_DIMENSIONS = 'dimensions'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; + public const KEY_MODEL_METADATA = 'modelMetadata'; + public const KEY_ADDITIONAL_DATA = 'additionalData'; + /** + * @var string Unique identifier for this result. + */ + private string $id; + /** + * @var list + */ + private array $embeddings; + /** + * @var int The vector dimension count. + */ + private int $dimensions; + /** + * @var TokenUsage Token usage statistics. + */ + private \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage; + /** + * @var ProviderMetadata Provider metadata. + */ + private ProviderMetadata $providerMetadata; + /** + * @var ModelMetadata Model metadata. + */ + private ModelMetadata $modelMetadata; + /** + * @var array + */ + private array $additionalData; + /** + * Constructor. + * + * @since 1.4.0 + * + * @param string $id Unique identifier for this result. + * @param list $embeddings The generated embedding vectors. + * @param int $dimensions The vector dimension count. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param ProviderMetadata $providerMetadata Provider metadata. + * @param ModelMetadata $modelMetadata Model metadata. + * @param array $additionalData Additional data. + */ + public function __construct(string $id, array $embeddings, int $dimensions, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = []) + { + if (empty($embeddings)) { + throw new InvalidArgumentException('At least one embedding must be provided'); + } + if ($dimensions < 1) { + throw new InvalidArgumentException('Embedding dimensions must be greater than zero'); + } + $normalizedEmbeddings = []; + foreach ($embeddings as $embedding) { + if (!$embedding instanceof \WordPress\AiClient\Results\DTO\Embedding) { + $embedding = new \WordPress\AiClient\Results\DTO\Embedding($embedding, $dimensions); + } elseif ($embedding->getDimensions() !== $dimensions) { + throw new InvalidArgumentException('Embedding vector length must match dimensions.'); + } + $normalizedEmbeddings[] = $embedding; + } + $this->id = $id; + $this->embeddings = $normalizedEmbeddings; + $this->dimensions = $dimensions; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + $this->additionalData = $additionalData; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getId(): string + { + return $this->id; + } + /** + * Gets the generated embedding vectors. + * + * @since 1.4.0 + * + * @return list The embeddings. + */ + public function getEmbeddings(): array + { + return $this->embeddings; + } + /** + * Gets the first generated embedding vector. + * + * @since 1.4.0 + * + * @return Embedding The first embedding. + */ + public function getEmbedding(): \WordPress\AiClient\Results\DTO\Embedding + { + return $this->embeddings[0]; + } + /** + * Gets the vector dimension count. + * + * @since 1.4.0 + * + * @return int The vector dimension count. + */ + public function getDimensions(): int + { + return $this->dimensions; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage + { + return $this->tokenUsage; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getProviderMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getModelMetadata(): ModelMetadata + { + return $this->modelMetadata; + } + /** + * {@inheritDoc} + * + * @since 1.4.0 + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } + /** + * Gets the JSON schema for embedding results. + * + * @since 1.4.0 + * + * @return array The JSON schema. + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_EMBEDDINGS => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Embedding::getJsonSchema(), 'description' => 'Generated embedding vectors.'], self::KEY_DIMENSIONS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Embedding vector dimensions.'], self::KEY_TOKEN_USAGE => \WordPress\AiClient\Results\DTO\TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), self::KEY_ADDITIONAL_DATA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Additional provider-specific data.']], 'required' => [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_DIMENSIONS, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]]; + } + /** + * Converts the embedding result to an array. + * + * @since 1.4.0 + * + * @return EmbeddingResultArrayShape The embedding result array. + */ + public function toArray(): array + { + $data = [self::KEY_ID => $this->id, self::KEY_EMBEDDINGS => array_map(static fn(\WordPress\AiClient\Results\DTO\Embedding $embedding): array => $embedding->toArray(), $this->embeddings), self::KEY_DIMENSIONS => $this->dimensions, self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray()]; + if (!empty($this->additionalData)) { + $data[self::KEY_ADDITIONAL_DATA] = $this->additionalData; + } + return $data; + } + /** + * Creates an embedding result from an array. + * + * @since 1.4.0 + * + * @param EmbeddingResultArrayShape $array The embedding result array. + * @return self The embedding result instance. + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_DIMENSIONS, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]); + return new self($array[self::KEY_ID], $array[self::KEY_EMBEDDINGS], $array[self::KEY_DIMENSIONS], \WordPress\AiClient\Results\DTO\TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), $array[self::KEY_ADDITIONAL_DATA] ?? []); + } +} diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php index 935459f44ec0a..38063ae24bf18 100644 --- a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php +++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php @@ -8,14 +8,15 @@ * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, - * including its name, description, and parameter schema. + * including its name, description, parameter schema, and optional annotations. * * @since 0.1.0 * * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array + * parameters?: array, + * annotations?: array * } * * @extends AbstractDataTransferObject @@ -25,6 +26,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; + public const KEY_ANNOTATIONS = 'annotations'; /** * @var string The name of the function. */ @@ -37,20 +39,27 @@ class FunctionDeclaration extends AbstractDataTransferObject * @var array|null The JSON schema for the function parameters. */ private ?array $parameters; + /** + * @var array Open-ended annotations which may be consumed by Providers. + */ + private array $annotations; /** * Constructor. * * @since 0.1.0 + * @since 1.5.0 Adds the optional $annotations parameter. * * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. + * @param array $annotations Optional annotations with JSON-serializable values. */ - public function __construct(string $name, string $description, ?array $parameters = null) + public function __construct(string $name, string $description, ?array $parameters = null, array $annotations = []) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; + $this->annotations = $annotations; } /** * Gets the function name. @@ -85,6 +94,17 @@ public function getParameters(): ?array { return $this->parameters; } + /** + * Gets the function annotations. + * + * @since 1.5.0 + * + * @return array The annotations, or an empty array if none were provided. + */ + public function getAnnotations(): array + { + return $this->annotations; + } /** * {@inheritDoc} * @@ -92,7 +112,7 @@ public function getParameters(): ?array */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]]; + return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true], self::KEY_ANNOTATIONS => ['type' => 'object', 'description' => 'Optional annotations interpreted by consumers.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]]; } /** * {@inheritDoc} @@ -107,6 +127,9 @@ public function toArray(): array if ($this->parameters !== null) { $data[self::KEY_PARAMETERS] = $this->parameters; } + if ($this->annotations !== []) { + $data[self::KEY_ANNOTATIONS] = $this->annotations; + } return $data; } /** @@ -117,6 +140,6 @@ public function toArray(): array public static function fromArray(array $array): self { static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_DESCRIPTION]); - return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null); + return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null, $array[self::KEY_ANNOTATIONS] ?? []); } } diff --git a/src/wp-settings.php b/src/wp-settings.php index 8c9ea4a86be37..07756eb0480c2 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -295,7 +295,9 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-discovery-strategy.php'; require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-event-dispatcher.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-builder.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-embedding-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php new file mode 100644 index 0000000000000..65c3b1f999c5b --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbedding.php @@ -0,0 +1,160 @@ +getProperty( 'builder' ); + if ( PHP_VERSION_ID < 80100 ) { + $builder_property->setAccessible( true ); + } + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $inputs_property = $reflection_class2->getProperty( 'inputs' ); + if ( PHP_VERSION_ID < 80100 ) { + $inputs_property->setAccessible( true ); + } + + return $inputs_property->getValue( $wrapped_builder ); + } + + /** + * Test that wp_ai_client_embedding() returns a WP_AI_Client_Embedding_Builder instance. + * + * @ticket 65638 + */ + public function test_returns_embedding_builder_instance() { + $builder = wp_ai_client_embedding(); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that successive calls return independent builder instances. + * + * @ticket 65638 + */ + public function test_returns_independent_instances() { + $builder1 = wp_ai_client_embedding( 'First' ); + $builder2 = wp_ai_client_embedding( 'Second' ); + + $this->assertNotSame( $builder1, $builder2 ); + } + + /** + * Test that calling with no arguments creates a builder without inputs or an error state. + * + * @ticket 65638 + */ + public function test_no_arguments_creates_builder_without_inputs() { + $builder = wp_ai_client_embedding(); + + $this->assertSame( array(), $this->get_wrapped_builder_inputs( $builder ) ); + } + + /** + * Test that a single argument becomes a single input. + * + * @ticket 65638 + */ + public function test_single_argument_becomes_single_input() { + $builder = wp_ai_client_embedding( 'Test input' ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 1, $inputs ); + $this->assertInstanceOf( MessagePart::class, $inputs[0] ); + } + + /** + * Test that multiple variadic arguments each become an independent input. + * + * @ticket 65638 + */ + public function test_variadic_arguments_become_independent_inputs() { + $builder = wp_ai_client_embedding( 'First', 'Second', 'Third' ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } + + /** + * Test that a single list argument becomes independent inputs. + * + * @ticket 65638 + */ + public function test_single_list_argument_becomes_independent_inputs() { + $builder = wp_ai_client_embedding( array( 'First', 'Second', 'Third' ) ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertSame( 'First', $inputs[0]->getText() ); + $this->assertSame( 'Second', $inputs[1]->getText() ); + $this->assertSame( 'Third', $inputs[2]->getText() ); + } + + /** + * Test that a single list argument can mix input types. + * + * @ticket 65638 + */ + public function test_single_list_argument_with_mixed_input_types() { + $part_shape = ( new MessagePart( 'Third' ) )->toArray(); + + $builder = wp_ai_client_embedding( array( 'First', new MessagePart( 'Second' ), $part_shape ) ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 3, $inputs ); + $this->assertSame( 'Third', $inputs[2]->getText() ); + } + + /** + * Test that a single message part array shape argument stays a single input. + * + * @ticket 65638 + */ + public function test_single_array_shape_argument_becomes_single_input() { + $builder = wp_ai_client_embedding( ( new MessagePart( 'Test input' ) )->toArray() ); + + $inputs = $this->get_wrapped_builder_inputs( $builder ); + + $this->assertCount( 1, $inputs ); + $this->assertSame( 'Test input', $inputs[0]->getText() ); + } + + /** + * Test that a single empty array argument creates a builder without inputs or an error state. + * + * @ticket 65638 + */ + public function test_empty_array_argument_creates_builder_without_inputs() { + $builder = wp_ai_client_embedding( array() ); + + $this->assertSame( array(), $this->get_wrapped_builder_inputs( $builder ) ); + + $error_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'error' ); + if ( PHP_VERSION_ID < 80100 ) { + $error_property->setAccessible( true ); + } + $this->assertNull( $error_property->getValue( $builder ), 'The builder should not be in an error state' ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php new file mode 100644 index 0000000000000..8cd40d36620ef --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEmbeddingBuilder.php @@ -0,0 +1,855 @@ +registry = $this->createMock( ProviderRegistry::class ); + $this->registry->method( 'isProviderConfigured' )->willReturn( true ); + } + + /** + * Makes a reflector accessible on PHP versions that require it. + * + * Since PHP 8.1 all reflection-accessed members are accessible by default, + * and PHP 8.5 deprecates the setAccessible() call. + * + * @param ReflectionProperty|ReflectionMethod $reflector The reflector to make accessible. + */ + private static function set_accessible( $reflector ) { + if ( PHP_VERSION_ID < 80100 ) { + $reflector->setAccessible( true ); + } + } + + /** + * Gets the value of a protected or private property from the wrapped embedding builder. + * + * Model selection state (e.g. `model`, `requestOptions`) lives on the builder's + * ModelResolver, so properties not found on the wrapped builder are looked up there. + * + * @param WP_AI_Client_Embedding_Builder $builder The WordPress embedding builder instance. + * @param string $property Property to get value for. + * @return mixed The property value. + */ + private function get_wrapped_builder_property_value( WP_AI_Client_Embedding_Builder $builder, string $property ) { + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + self::set_accessible( $builder_property ); + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + if ( ! $reflection_class2->hasProperty( $property ) ) { + $resolver_property = $reflection_class2->getProperty( 'modelResolver' ); + self::set_accessible( $resolver_property ); + $wrapped_builder = $resolver_property->getValue( $wrapped_builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + } + + $the_property = $reflection_class2->getProperty( $property ); + self::set_accessible( $the_property ); + + return $the_property->getValue( $wrapped_builder ); + } + + /** + * Returns the inputs held by the wrapped embedding builder. + * + * @param WP_AI_Client_Embedding_Builder $builder The embedding builder to read. + * @return string[] The text of each input, in order. + */ + private function get_input_texts( WP_AI_Client_Embedding_Builder $builder ): array { + $texts = array(); + foreach ( $this->get_wrapped_builder_property_value( $builder, 'inputs' ) as $part ) { + $texts[] = (string) $part->getText(); + } + + return $texts; + } + + /** + * Creates a test model metadata instance for embedding generation. + * + * @return ModelMetadata + */ + private function create_test_embedding_model_metadata(): ModelMetadata { + return new ModelMetadata( + 'test-embedding-model', + 'Test Embedding Model', + array( CapabilityEnum::embeddingGeneration() ), + array( new SupportedOption( OptionEnum::inputModalities() ) ) + ); + } + + /** + * Creates a test EmbeddingResult with the given number of embeddings. + * + * @param int $count The number of embeddings in the result. + * @param int $dimensions Optional. The dimensions of each embedding. Default 3. + * @return EmbeddingResult + */ + private function create_test_embedding_result( int $count, int $dimensions = 3 ): EmbeddingResult { + $embeddings = array(); + for ( $i = 0; $i < $count; $i++ ) { + $embeddings[] = new Embedding( array_fill( 0, $dimensions, 0.1 * ( $i + 1 ) ), $dimensions ); + } + + return new EmbeddingResult( + 'test-embedding-result', + $embeddings, + $dimensions, + new TokenUsage( 10, 0, 10 ), + new ProviderMetadata( 'mock', 'Mock Provider', ProviderTypeEnum::cloud() ), + $this->create_test_embedding_model_metadata() + ); + } + + /** + * Creates a mock embedding generation model using an anonymous class. + * + * @param EmbeddingResult|Throwable $result The result to return from generation, + * or a throwable to throw instead. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&EmbeddingGenerationModelInterface The mock model. + */ + private function create_mock_embedding_model( + $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_embedding_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, EmbeddingGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + /** @var EmbeddingResult|Throwable */ + private $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateEmbeddingResult( array $inputs ): EmbeddingResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + if ( $this->result instanceof Throwable ) { + throw $this->result; + } + return $this->result; + } + }; + } + + /** + * Test that WP_AI_Client_Embedding_Builder can be instantiated. + * + * @ticket 65638 + */ + public function test_instantiation() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that WP_AI_Client_Embedding_Builder can be instantiated with initial input. + * + * @ticket 65638 + */ + public function test_instantiation_with_input() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $builder ); + } + + /** + * Test that the constructor parses a single string input into one message part. + * + * @ticket 65638 + */ + public function test_constructor_with_string_input() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 1, $inputs ); + $this->assertInstanceOf( MessagePart::class, $inputs[0] ); + } + + /** + * Test that the constructor parses a list of inputs into one message part each. + * + * @ticket 65638 + */ + public function test_constructor_with_input_list() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 2, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } + + /** + * Test that the constructor sets the default request timeout. + * + * @ticket 65638 + */ + public function test_constructor_sets_default_request_timeout() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertSame( 30.0, $request_options->getTimeout() ); + } + + /** + * Test that the request timeout filter receives the embedding builder type. + * + * @ticket 65638 + */ + public function test_request_timeout_filter_receives_builder_type() { + $received_builder_type = null; + + add_filter( + 'wp_ai_client_default_request_timeout', + static function ( $default_timeout, $builder_type ) use ( &$received_builder_type ) { + $received_builder_type = $builder_type; + return $default_timeout; + }, + 10, + 2 + ); + + new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + $this->assertSame( WP_AI_Client_Embedding_Builder::class, $received_builder_type ); + } + + /** + * Test that the constructor allows overriding the default request timeout. + * + * @ticket 65638 + */ + public function test_constructor_allows_overriding_request_timeout() { + add_filter( + 'wp_ai_client_default_request_timeout', + static function () { + return 45.5; + } + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertSame( 45.5, $request_options->getTimeout() ); + } + + /** + * Test that the constructor rejects an invalid request timeout, reporting the concrete class name. + * + * @ticket 65638 + * + * @expectedIncorrectUsage WP_AI_Client_Embedding_Builder::__construct + */ + public function test_constructor_disallows_overriding_with_invalid_request_timeout() { + add_filter( 'wp_ai_client_default_request_timeout', '__return_null' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_builder_property_value( $builder, 'requestOptions' ); + + $this->assertSame( 30.0, $request_options->getTimeout() ); + } + + /** + * Test that fluent methods return the wrapper instance for method chaining. + * + * @ticket 65638 + */ + public function test_method_chaining_returns_wrapper() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $result = $builder->with_input( 'Test input' ); + $this->assertSame( $builder, $result, 'with_input() should return the wrapper instance' ); + + $result = $builder->using_dimensions( 256 ); + $this->assertSame( $builder, $result, 'using_dimensions() should return the wrapper instance' ); + } + + /** + * Test that with_input() appends inputs to the wrapped builder. + * + * @ticket 65638 + */ + public function test_with_input_appends_inputs() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'First' ); + $builder->with_input( 'Second', 'Third' ); + + $inputs = $this->get_wrapped_builder_property_value( $builder, 'inputs' ); + + $this->assertCount( 3, $inputs ); + $this->assertContainsOnlyInstancesOf( MessagePart::class, $inputs ); + } + + /** + * Test that using_dimensions() sets the dimensions on the model configuration. + * + * @ticket 65638 + */ + public function test_using_dimensions_sets_model_config() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + $builder->using_dimensions( 256 ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_builder_property_value( $builder, 'modelConfig' ); + + $this->assertSame( 256, $config->getDimensions() ); + } + + /** + * Test that calling a nonexistent method puts the builder in an error state. + * + * @ticket 65638 + */ + public function test_invalid_method_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $builder->nonexistent_method(); + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_builder_error', $result->get_error_code() ); + } + + /** + * Test that once in error state, subsequent fluent calls return the same instance. + * + * @ticket 65638 + */ + public function test_error_state_fluent_calls_return_same_instance() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + // Trigger an error state by calling a nonexistent method. + $builder->nonexistent_method(); + + $result = $builder->with_input( 'Test' ); + $this->assertSame( $builder, $result, 'Fluent method should return same instance when in error state' ); + + $result = $builder->using_dimensions( 256 ); + $this->assertSame( $builder, $result, 'Fluent method should return same instance when in error state' ); + } + + /** + * Test that is_supported() returns false when in error state. + * + * @ticket 65638 + */ + public function test_support_check_method_returns_false_in_error_state() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + // Trigger an error state by calling a nonexistent method. + $builder->nonexistent_method(); + + $this->assertFalse( $builder->is_supported(), 'is_supported should return false when in error state' ); + } + + /** + * Test that an invalid constructor input puts the builder in an error state. + * + * @ticket 65638 + */ + public function test_constructor_with_invalid_input_enters_error_state() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, '' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that generating without any inputs returns a WP_Error. + * + * @ticket 65638 + */ + public function test_generate_embeddings_without_input_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that is_supported() returns false when AI is not supported. + * + * @ticket 65638 + */ + public function test_is_supported_returns_false_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that is_supported() returns false when the prevent embedding filter returns true. + * + * @ticket 65638 + */ + public function test_is_supported_returns_false_when_filter_prevents_embedding() { + add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that is_supported() returns true for a model with the embedding generation capability. + * + * @ticket 65638 + */ + public function test_is_supported_with_capable_model() { + $model = $this->create_mock_embedding_model( $this->create_test_embedding_result( 1 ) ); + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $this->assertTrue( $builder->using_model( $model )->is_supported() ); + } + + /** + * Test that is_supported() returns false when no model was specified. + * + * Embeddings are only comparable to other embeddings from the same model, so + * the SDK never selects a model automatically. + * + * @ticket 65638 + */ + public function test_is_supported_returns_false_without_model() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Test that generating without a model returns a WP_Error. + * + * @ticket 65638 + */ + public function test_generate_embeddings_without_model_returns_wp_error() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $error = $builder->generate_embeddings(); + + $this->assertWPError( $error ); + $this->assertSame( 'embedding_invalid_argument', $error->get_error_code() ); + } + + /** + * Test that is_supported() returns false when the model's provider is not configured. + * + * @ticket 65638 + */ + public function test_is_supported_returns_false_when_provider_not_configured() { + $registry = $this->createMock( ProviderRegistry::class ); + $registry->method( 'isProviderConfigured' )->willReturn( false ); + + $model = $this->create_mock_embedding_model( $this->create_test_embedding_result( 1 ) ); + $builder = new WP_AI_Client_Embedding_Builder( $registry, 'Test input' ); + + $this->assertFalse( $builder->using_model( $model )->is_supported() ); + } + + /** + * Test that using_provider_model() retrieves the model from the registry. + * + * @ticket 65638 + */ + public function test_using_provider_model_generates_with_registry_model() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( 'mock', 'test-embedding-model' ) + ->willReturn( $model ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $actual_result = $builder->using_provider_model( 'mock', 'test-embedding-model' )->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Test that generating returns a WP_Error when the prevent embedding filter returns true. + * + * @ticket 65638 + */ + public function test_generate_embeddings_returns_wp_error_when_filter_prevents_embedding() { + add_filter( 'wp_ai_client_prevent_embedding', '__return_true' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_prevented', $result->get_error_code() ); + $this->assertSame( 'Embedding generation was prevented by a filter.', $result->get_error_message() ); + $this->assertSame( 503, $result->get_error_data()['status'] ); + } + + /** + * Test that generating returns a WP_Error when AI is not supported. + * + * @ticket 65638 + */ + public function test_generate_embeddings_returns_wp_error_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + $result = $builder->generate_embeddings(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_prevented', $result->get_error_code() ); + $this->assertSame( 'AI features are not supported in this environment.', $result->get_error_message() ); + } + + /** + * Test that the prevent embedding filter receives a clone of the builder instance. + * + * @ticket 65638 + */ + public function test_prevent_embedding_filter_receives_cloned_builder_instance() { + $captured_builder = null; + + add_filter( + 'wp_ai_client_prevent_embedding', + static function ( $prevent, $builder ) use ( &$captured_builder ) { + $captured_builder = $builder; + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + + // Test with is_supported(). + $builder->is_supported(); + $this->assertNotSame( $builder, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $captured_builder ); + + // Reset and test with generate_embeddings(). + $captured_builder = null; + $builder2 = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Test input' ); + $builder2->generate_embeddings(); + $this->assertNotSame( $builder2, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Embedding_Builder::class, $captured_builder ); + } + + /** + * Test that generate_embedding_result() returns the result from the model. + * + * @ticket 65638 + */ + public function test_generate_embedding_result() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Test that generate_embedding_result() fires the embedding lifecycle action hooks in order. + * + * @ticket 65638 + */ + public function test_generate_embedding_result_fires_lifecycle_action_hooks() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $hook_order = array(); + $events = array(); + + add_action( + 'wp_ai_client_before_generate_embedding', + static function ( $event ) use ( &$hook_order, &$events ) { + $hook_order[] = 'before'; + $events['before'] = $event; + } + ); + add_action( + 'wp_ai_client_after_generate_embedding', + static function ( $event ) use ( &$hook_order, &$events ) { + $hook_order[] = 'after'; + $events['after'] = $event; + } + ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_embedding_result(); + + $this->assertSame( $result, $actual_result ); + $this->assertSame( array( 'before', 'after' ), $hook_order ); + $this->assertInstanceOf( BeforeGenerateEmbeddingEvent::class, $events['before'] ); + $this->assertInstanceOf( AfterGenerateEmbeddingEvent::class, $events['after'] ); + $this->assertSame( $result, $events['after']->getResult() ); + } + + /** + * Test that generate_embedding() returns a single embedding. + * + * @ticket 65638 + */ + public function test_generate_embedding() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $builder->using_model( $model ); + + $embedding = $builder->generate_embedding(); + + $this->assertInstanceOf( Embedding::class, $embedding ); + $this->assertSame( $result->getEmbedding(), $embedding ); + } + + /** + * Test that generate_embeddings() returns one embedding per input. + * + * @ticket 65638 + */ + public function test_generate_embeddings() { + $result = $this->create_test_embedding_result( 2 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + $builder->using_model( $model ); + + $embeddings = $builder->generate_embeddings(); + + $this->assertIsArray( $embeddings ); + $this->assertCount( 2, $embeddings ); + $this->assertSame( $result->getEmbeddings(), $embeddings ); + } + + /** + * Test that generate_embedding() returns a WP_Error when multiple inputs are configured. + * + * @ticket 65638 + */ + public function test_generate_embedding_returns_wp_error_with_multiple_inputs() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + + $result = $builder->generate_embedding(); + + $this->assertWPError( $result ); + $this->assertSame( 'embedding_invalid_argument', $result->get_error_code() ); + } + + /** + * Test that a mismatched embedding count from the model results in a WP_Error. + * + * @ticket 65638 + */ + public function test_generate_embeddings_returns_wp_error_on_count_mismatch() { + $result = $this->create_test_embedding_result( 1 ); + $model = $this->create_mock_embedding_model( $result ); + + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, array( 'First', 'Second' ) ); + $builder->using_model( $model ); + + $error = $builder->generate_embeddings(); + + $this->assertWPError( $error ); + $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); + } + + /** + * Test that a throwable from the model is returned as a WP_Error with an embedding-prefixed code. + * + * @ticket 65638 + * + * @dataProvider data_generate_embedding_result_maps_throwable_to_wp_error + * + * @param Throwable $throwable The throwable thrown by the model. + * @param string $expected_code The expected WP_Error code. + * @param int $expected_status The expected HTTP status in the error data. + */ + public function test_generate_embedding_result_maps_throwable_to_wp_error( Throwable $throwable, string $expected_code, int $expected_status ) { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + $error = $builder->using_model( $this->create_mock_embedding_model( $throwable ) )->generate_embedding_result(); + + $this->assertWPError( $error ); + + $this->assertSame( $expected_code, $error->get_error_code() ); + $this->assertSame( $throwable->getMessage(), $error->get_error_message() ); + $this->assertSame( $expected_status, $error->get_error_data()['status'] ); + $this->assertSame( get_class( $throwable ), $error->get_error_data()['exception_class'] ); + } + + /** + * Data provider for {@see self::test_generate_embedding_result_maps_throwable_to_wp_error()}. + * + * @return array + */ + public static function data_generate_embedding_result_maps_throwable_to_wp_error(): array { + return array( + 'NetworkException' => array( new NetworkException( 'network error' ), 'embedding_network_error', 503 ), + 'ClientException with code' => array( new ClientException( 'unauthorized', 401 ), 'embedding_client_error', 401 ), + 'ClientException without code' => array( new ClientException( 'bad request' ), 'embedding_client_error', 400 ), + 'ServerException with code' => array( new ServerException( 'bad gateway', 502 ), 'embedding_upstream_server_error', 502 ), + 'ServerException without code' => array( new ServerException( 'server error' ), 'embedding_upstream_server_error', 500 ), + 'TokenLimitReachedException' => array( new TokenLimitReachedException( 'token limit' ), 'embedding_token_limit_reached', 400 ), + 'InvalidArgumentException' => array( new AiClientInvalidArgumentException( 'invalid arg' ), 'embedding_invalid_argument', 400 ), + 'generic Exception' => array( new Exception( 'generic' ), 'embedding_builder_error', 500 ), + 'TypeError' => array( new TypeError( 'type error' ), 'embedding_builder_error', 500 ), + ); + } + + /** + * Test that a TypeError thrown by the wrapped SDK is caught and returned as a WP_Error. + * + * `usingDimensions()` expects an int; an array can never be coerced and throws a + * TypeError, which extends Error rather than Exception. + * + * @ticket 65638 + * @ticket 65505 + */ + public function test_call_catches_type_error_from_invalid_argument_type() { + $builder = new WP_AI_Client_Embedding_Builder( $this->registry, 'Test input' ); + + $result = $builder->using_dimensions( array( 256 ) ); + + $this->assertSame( $builder, $result, 'The fluent interface should be preserved' ); + + $error = $result->generate_embeddings(); + $this->assertWPError( $error ); + $this->assertSame( 'embedding_builder_error', $error->get_error_code() ); + $this->assertSame( 'TypeError', $error->get_error_data()['exception_class'] ); + } + + /** + * Test that a clone does not share the wrapped builder with the original. + * + * @ticket 65638 + * @ticket 65782 + */ + public function test_clone_does_not_share_the_wrapped_builder() { + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Original input' ); + $clone = clone $builder; + + $clone->with_input( 'Added to the clone' ); + + $this->assertSame( array( 'Original input' ), $this->get_input_texts( $builder ), 'Changing the clone should not change the original' ); + } + + /** + * Test that the clone passed to the prevent embedding filter cannot change the inputs. + * + * @ticket 65638 + * @ticket 65782 + */ + public function test_prevent_embedding_filter_cannot_mutate_the_original_inputs() { + add_filter( + 'wp_ai_client_prevent_embedding', + static function ( $prevent, $builder ) { + $builder->with_input( 'Added by the filter' ); + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Embedding_Builder( AiClient::defaultRegistry(), 'Original input' ); + $builder->is_supported(); + + $this->assertSame( array( 'Original input' ), $this->get_input_texts( $builder ), 'The filter should not change the original inputs' ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index e76918c2d02e4..bb1fe9c5baacd 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -96,18 +96,30 @@ private static function set_accessible( $reflector ) { /** * Gets the value of a protected or private property from the wrapped prompt builder. * + * Since PHP AI Client 1.4.0, model selection state (e.g. `model`, `registry`, + * `providerIdOrClassName`, `requestOptions`) lives on the builder's ModelResolver, + * so properties not found on the wrapped builder are looked up there. + * * @param WP_AI_Client_Prompt_Builder $builder The WordPress prompt builder instance. * @param string $property Property to get value for. * @return mixed The property value. */ private function get_wrapped_prompt_builder_property_value( WP_AI_Client_Prompt_Builder $builder, string $property ) { - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); self::set_accessible( $builder_property ); $wrapped_builder = $builder_property->getValue( $builder ); $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); - $the_property = $reflection_class2->getProperty( $property ); + if ( ! $reflection_class2->hasProperty( $property ) ) { + $resolver_property = $reflection_class2->getProperty( 'modelResolver' ); + self::set_accessible( $resolver_property ); + $wrapped_builder = $resolver_property->getValue( $wrapped_builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + } + + $the_property = $reflection_class2->getProperty( $property ); self::set_accessible( $the_property ); return $the_property->getValue( $wrapped_builder ); @@ -191,6 +203,29 @@ public function test_constructor_sets_default_request_timeout() { $this->assertSame( 30.0, $request_options->getTimeout() ); } + /** + * Test that the request timeout filter receives the builder type. + * + * @ticket 65638 + */ + public function test_request_timeout_filter_receives_builder_type() { + $received_builder_type = null; + + add_filter( + 'wp_ai_client_default_request_timeout', + static function ( $default_timeout, $builder_type ) use ( &$received_builder_type ) { + $received_builder_type = $builder_type; + return $default_timeout; + }, + 10, + 2 + ); + + new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + $this->assertSame( WP_AI_Client_Prompt_Builder::class, $received_builder_type ); + } + /** * Test that the constructor allows overriding the default request timeout with a valid value. * @@ -376,15 +411,7 @@ public function test_wrapped_builder_has_correct_registry() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $builder_property = $reflection_class->getProperty( 'builder' ); - self::set_accessible( $builder_property ); - $wrapped_builder = $builder_property->getValue( $prompt_builder ); - - $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) ); - $registry_property = $wrapped_builder_reflection->getProperty( 'registry' ); - self::set_accessible( $registry_property ); - $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' ); + $this->assertSame( $registry, $this->get_wrapped_prompt_builder_property_value( $prompt_builder, 'registry' ), 'Wrapped builder should have the same registry' ); } /** @@ -1393,7 +1420,7 @@ public function test_validate_messages_non_user_last_returns_wp_error() { ); // Manually add a model message as the last message. - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection_class = new ReflectionClass( WP_AI_Client_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); self::set_accessible( $builder_property ); $wrapped_builder = $builder_property->getValue( $builder ); @@ -2620,7 +2647,7 @@ static function ( $prevent, $builder ) use ( &$captured_builder ) { * @return string[] The text of each message part, in order. */ private function get_prompt_parts( WP_AI_Client_Prompt_Builder $builder ): array { - $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $wrapped = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $wrapped ); $inner = $wrapped->getValue( $builder ); @@ -2646,7 +2673,7 @@ public function test_clone_does_not_share_the_wrapped_builder() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Original prompt' ); $clone = clone $builder; - $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $wrapped = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $wrapped ); $this->assertNotSame( @@ -2735,7 +2762,7 @@ public function test_support_check_methods_return_false_when_builder_throws( $me $wrapped_builder->method( $wrapped_method ) ->willThrowException( new RuntimeException( 'Thrown by the wrapped builder.' ) ); - $builder_property = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' ); + $builder_property = new ReflectionProperty( WP_AI_Client_Builder::class, 'builder' ); self::set_accessible( $builder_property ); $builder_property->setValue( $prompt_builder, $wrapped_builder ); @@ -2918,7 +2945,7 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat * @return WP_Error The resulting WP_Error. */ private function invoke_throwable_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $throwable ): WP_Error { - $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $reflection = new ReflectionClass( WP_AI_Client_Builder::class ); $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method );