diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index a8387b3604c9b..3f2514003e14c 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -73,6 +73,7 @@
/src/wp-includes/js/*
/src/wp-includes/PHPMailer/*
/src/wp-includes/Requests/*
+ /src/wp-includes/php-ai-client/*
/src/wp-includes/SimplePie/*
/src/wp-includes/sodium_compat/*
/src/wp-includes/Text/*
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 4b5b0d3ded110..fa1b8805a91ec 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -47,6 +47,8 @@
src/wp-includes/IXR
src/wp-includes/PHPMailer
src/wp-includes/Requests
+ src/wp-includes/php-ai-client
+ src/wp-includes/ai-client-utils
src/wp-includes/SimplePie
src/wp-includes/sodium_compat
src/wp-includes/Text
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php
new file mode 100644
index 0000000000000..e50b86da50165
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php
@@ -0,0 +1,181 @@
+getName();
+ if ( null === $name ) {
+ return false;
+ }
+
+ return str_starts_with( $name, self::ABILITY_PREFIX );
+ }
+
+ /**
+ * Executes a WordPress ability from a function call.
+ *
+ * @since 7.0.0
+ *
+ * @param FunctionCall $call The function call to execute.
+ * @return FunctionResponse The response from executing the ability.
+ */
+ public static function execute_ability( FunctionCall $call ): FunctionResponse {
+ $function_name = $call->getName() ?? 'unknown';
+ $function_id = $call->getId() ?? 'unknown';
+
+ if ( ! self::is_ability_call( $call ) ) {
+ return new FunctionResponse(
+ $function_id,
+ $function_name,
+ array(
+ 'error' => 'Not an ability function call',
+ 'code' => 'invalid_ability_call',
+ )
+ );
+ }
+
+ $ability_name = self::function_name_to_ability_name( $function_name );
+ $ability = wp_get_ability( $ability_name );
+
+ if ( ! $ability instanceof WP_Ability ) {
+ return new FunctionResponse(
+ $function_id,
+ $function_name,
+ array(
+ 'error' => sprintf( 'Ability "%s" not found', $ability_name ),
+ 'code' => 'ability_not_found',
+ )
+ );
+ }
+
+ $args = $call->getArgs();
+ $result = $ability->execute( ! empty( $args ) ? $args : null );
+
+ if ( is_wp_error( $result ) ) {
+ return new FunctionResponse(
+ $function_id,
+ $function_name,
+ array(
+ 'error' => $result->get_error_message(),
+ 'code' => $result->get_error_code(),
+ 'data' => $result->get_error_data(),
+ )
+ );
+ }
+
+ return new FunctionResponse(
+ $function_id,
+ $function_name,
+ $result
+ );
+ }
+
+ /**
+ * Checks if a message contains any ability function calls.
+ *
+ * @since 7.0.0
+ *
+ * @param Message $message The message to check.
+ * @return bool True if the message contains ability calls, false otherwise.
+ */
+ public static function has_ability_calls( Message $message ): bool {
+ foreach ( $message->getParts() as $part ) {
+ if ( $part->getType()->isFunctionCall() ) {
+ $function_call = $part->getFunctionCall();
+ if ( $function_call instanceof FunctionCall && self::is_ability_call( $function_call ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Executes all ability function calls in a message.
+ *
+ * @since 7.0.0
+ *
+ * @param Message $message The message containing function calls.
+ * @return Message A new message with function responses.
+ */
+ public static function execute_abilities( Message $message ): Message {
+ $response_parts = array();
+
+ foreach ( $message->getParts() as $part ) {
+ if ( $part->getType()->isFunctionCall() ) {
+ $function_call = $part->getFunctionCall();
+ if ( $function_call instanceof FunctionCall ) {
+ $function_response = self::execute_ability( $function_call );
+ $response_parts[] = new MessagePart( $function_response );
+ }
+ }
+ }
+
+ return new UserMessage( $response_parts );
+ }
+
+ /**
+ * Converts an ability name to a function name.
+ *
+ * Transforms "tec/create_event" to "wpab__tec__create_event".
+ *
+ * @since 7.0.0
+ *
+ * @param string $ability_name The ability name to convert.
+ * @return string The function name.
+ */
+ public static function ability_name_to_function_name( string $ability_name ): string {
+ return self::ABILITY_PREFIX . str_replace( '/', '__', $ability_name );
+ }
+
+ /**
+ * Converts a function name to an ability name.
+ *
+ * Transforms "wpab__tec__create_event" to "tec/create_event".
+ *
+ * @since 7.0.0
+ *
+ * @param string $function_name The function name to convert.
+ * @return string The ability name.
+ */
+ private static function function_name_to_ability_name( string $function_name ): string {
+ $without_prefix = substr( $function_name, strlen( self::ABILITY_PREFIX ) );
+
+ return str_replace( '__', '/', $without_prefix );
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php
new file mode 100644
index 0000000000000..ca19cb6de77bf
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php
@@ -0,0 +1,209 @@
+ttl_to_seconds( $ttl );
+
+ return wp_cache_set( $key, $value, self::CACHE_GROUP, $expire );
+ }
+
+ /**
+ * Delete an item from the cache by its unique key.
+ *
+ * @since 7.0.0
+ *
+ * @param string $key The unique cache key of the item to delete.
+ * @return bool True if the item was successfully removed. False if there was an error.
+ */
+ public function delete( $key ): bool {
+ return wp_cache_delete( $key, self::CACHE_GROUP );
+ }
+
+ /**
+ * Wipes clean the entire cache's keys.
+ *
+ * This method only clears the cache group used by this adapter. If the underlying
+ * cache implementation does not support group flushing, this method returns false.
+ *
+ * @since 7.0.0
+ *
+ * @return bool True on success and false on failure.
+ */
+ public function clear(): bool {
+ if ( ! function_exists( 'wp_cache_supports' ) || ! wp_cache_supports( 'flush_group' ) ) {
+ return false;
+ }
+
+ return wp_cache_flush_group( self::CACHE_GROUP );
+ }
+
+ /**
+ * Obtains multiple cache items by their unique keys.
+ *
+ * @since 7.0.0
+ *
+ * @param iterable $keys A list of keys that can be obtained in a single operation.
+ * @param mixed $default_value Default value to return for keys that do not exist.
+ * @return array A list of key => value pairs.
+ */
+ public function getMultiple( $keys, $default_value = null ) {
+ /**
+ * Keys array.
+ *
+ * @var array $keys_array
+ */
+ $keys_array = $this->iterable_to_array( $keys );
+ $values = wp_cache_get_multiple( $keys_array, self::CACHE_GROUP );
+ $result = array();
+
+ foreach ( $keys_array as $key ) {
+ $result[ $key ] = isset( $values[ $key ] ) && false !== $values[ $key ] ? $values[ $key ] : $default_value;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Persists a set of key => value pairs in the cache, with an optional TTL.
+ *
+ * @since 7.0.0
+ *
+ * @param iterable $values A list of key => value pairs for a multiple-set operation.
+ * @param null|int|DateInterval $ttl Optional. The TTL value of this item.
+ * @return bool True on success and false on failure.
+ */
+ public function setMultiple( $values, $ttl = null ): bool {
+ $values_array = $this->iterable_to_array( $values );
+ $expire = $this->ttl_to_seconds( $ttl );
+ $results = wp_cache_set_multiple( $values_array, self::CACHE_GROUP, $expire );
+
+ // Return true only if all operations succeeded.
+ return ! in_array( false, $results, true );
+ }
+
+ /**
+ * Deletes multiple cache items in a single operation.
+ *
+ * @since 7.0.0
+ *
+ * @param iterable $keys A list of string-based keys to be deleted.
+ * @return bool True if the items were successfully removed. False if there was an error.
+ */
+ public function deleteMultiple( $keys ): bool {
+ $keys_array = $this->iterable_to_array( $keys );
+ $results = wp_cache_delete_multiple( $keys_array, self::CACHE_GROUP );
+
+ // Return true only if all operations succeeded.
+ return ! in_array( false, $results, true );
+ }
+
+ /**
+ * Determines whether an item is present in the cache.
+ *
+ * @since 7.0.0
+ *
+ * @param string $key The cache item key.
+ * @return bool True if the item exists in the cache, false otherwise.
+ */
+ public function has( $key ): bool {
+ $found = false;
+ wp_cache_get( $key, self::CACHE_GROUP, false, $found );
+
+ return (bool) $found;
+ }
+
+ /**
+ * Converts a PSR-16 TTL value to seconds for WordPress cache functions.
+ *
+ * @since 7.0.0
+ *
+ * @param null|int|DateInterval $ttl The TTL value.
+ * @return int The TTL in seconds, or 0 for no expiration.
+ */
+ private function ttl_to_seconds( $ttl ): int {
+ if ( null === $ttl ) {
+ return 0;
+ }
+
+ if ( $ttl instanceof DateInterval ) {
+ $now = new DateTime();
+ $end = ( clone $now )->add( $ttl );
+
+ return $end->getTimestamp() - $now->getTimestamp();
+ }
+
+ return max( 0, (int) $ttl );
+ }
+
+ /**
+ * Converts an iterable to an array.
+ *
+ * @since 7.0.0
+ *
+ * @param iterable $items The iterable to convert.
+ * @return array The array.
+ */
+ private function iterable_to_array( $items ): array {
+ if ( is_array( $items ) ) {
+ return $items;
+ }
+
+ return iterator_to_array( $items );
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php
new file mode 100644
index 0000000000000..80bdea4968617
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php
@@ -0,0 +1,90 @@
+> List of candidates.
+ */
+ public static function getCandidates( $type ) {
+ if ( ClientInterface::class === $type ) {
+ return array(
+ array(
+ 'class' => static function () {
+ return self::create_wordpress_client();
+ },
+ ),
+ );
+ }
+
+ $psr17_factories = array(
+ 'WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface',
+ 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface',
+ 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface',
+ 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface',
+ 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface',
+ 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface',
+ );
+
+ if ( in_array( $type, $psr17_factories, true ) ) {
+ return array(
+ array(
+ 'class' => WP_AI_Client_PSR17_Factory::class,
+ ),
+ );
+ }
+
+ return array();
+ }
+
+ /**
+ * Creates an instance of the WordPress HTTP client.
+ *
+ * @since 7.0.0
+ *
+ * @return WP_AI_Client_HTTP_Client
+ */
+ private static function create_wordpress_client() {
+ $psr17_factory = new WP_AI_Client_PSR17_Factory();
+ return new WP_AI_Client_HTTP_Client(
+ $psr17_factory,
+ $psr17_factory
+ );
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php
new file mode 100644
index 0000000000000..9eeb85b32a6c0
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php
@@ -0,0 +1,82 @@
+get_hook_name_portion_for_event( $event );
+
+ /**
+ * Fires when an AI client event is dispatched.
+ *
+ * The dynamic portion of the hook name, `$event_name`, refers to the
+ * snake_case version of the event class name, without the `_event` suffix.
+ *
+ * For example, an event class named `BeforeGenerateResultEvent` will fire the
+ * `wp_ai_client_before_generate_result` action hook.
+ *
+ * In practice, the available action hook names are:
+ *
+ * - wp_ai_client_before_generate_result
+ * - wp_ai_client_after_generate_result
+ *
+ * @since 7.0.0
+ *
+ * @param object $event The event object.
+ */
+ do_action( "wp_ai_client_{$event_name}", $event );
+
+ return $event;
+ }
+
+ /**
+ * Converts an event object class name to a WordPress action hook name portion.
+ *
+ * @since 7.0.0
+ *
+ * @param object $event The event object.
+ * @return string The hook name portion derived from the event class name.
+ */
+ private function get_hook_name_portion_for_event( object $event ): string {
+ $class_name = get_class( $event );
+ $pos = strrpos( $class_name, '\\' );
+ $short_name = false !== $pos ? substr( $class_name, $pos + 1 ) : $class_name;
+
+ // Convert PascalCase to snake_case.
+ $snake_case = strtolower( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $short_name ) );
+
+ // Strip '_event' suffix if present.
+ if ( str_ends_with( $snake_case, '_event' ) ) {
+ $snake_case = (string) substr( $snake_case, 0, -6 );
+ }
+
+ return $snake_case;
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php
new file mode 100644
index 0000000000000..bddcde6cf62c0
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php
@@ -0,0 +1,229 @@
+response_factory = $response_factory;
+ $this->stream_factory = $stream_factory;
+ }
+
+ /**
+ * Sends a PSR-7 request and returns a PSR-7 response.
+ *
+ * @since 7.0.0
+ *
+ * @param RequestInterface $request The PSR-7 request.
+ * @return ResponseInterface The PSR-7 response.
+ *
+ * @throws NetworkException If the WordPress HTTP request fails.
+ */
+ public function sendRequest( RequestInterface $request ): ResponseInterface {
+ $args = $this->prepare_wp_args( $request );
+ $url = (string) $request->getUri();
+
+ $response = wp_remote_request( $url, $args );
+
+ if ( is_wp_error( $response ) ) {
+ $message = sprintf(
+ 'Network error occurred while sending %s request to %s: %s',
+ $request->getMethod(),
+ $url,
+ $response->get_error_message()
+ );
+
+ throw new NetworkException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ return $this->create_psr_response( $response );
+ }
+
+ /**
+ * Sends a PSR-7 request with transport options and returns a PSR-7 response.
+ *
+ * @since 7.0.0
+ *
+ * @param RequestInterface $request The PSR-7 request.
+ * @param RequestOptions $options Transport options for the request.
+ * @return ResponseInterface The PSR-7 response.
+ *
+ * @throws NetworkException If the WordPress HTTP request fails.
+ */
+ public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface {
+ $args = $this->prepare_wp_args( $request, $options );
+ $url = (string) $request->getUri();
+
+ $response = wp_remote_request( $url, $args );
+
+ if ( is_wp_error( $response ) ) {
+ $message = sprintf(
+ 'Network error occurred while sending request to %s: %s',
+ $url,
+ $response->get_error_message()
+ );
+
+ throw new NetworkException(
+ $message, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ $response->get_error_code() ? (int) $response->get_error_code() : 0
+ );
+ }
+
+ return $this->create_psr_response( $response );
+ }
+
+ /**
+ * Prepares WordPress HTTP API arguments from a PSR-7 request.
+ *
+ * @since 7.0.0
+ *
+ * @param RequestInterface $request The PSR-7 request.
+ * @param RequestOptions|null $options Optional transport options for the request.
+ * @return array WordPress HTTP API arguments.
+ */
+ private function prepare_wp_args( RequestInterface $request, ?RequestOptions $options = null ): array {
+ $args = array(
+ 'method' => $request->getMethod(),
+ 'headers' => $this->prepare_headers( $request ),
+ 'body' => $this->prepare_body( $request ),
+ 'httpversion' => $request->getProtocolVersion(),
+ 'blocking' => true,
+ );
+
+ if ( null !== $options ) {
+ if ( null !== $options->getTimeout() ) {
+ $args['timeout'] = $options->getTimeout();
+ }
+
+ if ( null !== $options->getMaxRedirects() ) {
+ $args['redirection'] = $options->getMaxRedirects();
+ }
+ }
+
+ return $args;
+ }
+
+ /**
+ * Prepares headers for WordPress HTTP API.
+ *
+ * @since 7.0.0
+ *
+ * @param RequestInterface $request The PSR-7 request.
+ * @return array Headers array for WordPress HTTP API.
+ */
+ private function prepare_headers( RequestInterface $request ): array {
+ $headers = array();
+
+ foreach ( $request->getHeaders() as $name => $values ) {
+ if ( strpos( $name, 'X-Stream' ) === 0 ) {
+ continue;
+ }
+
+ $headers[ (string) $name ] = implode( ', ', $values );
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Prepares request body for WordPress HTTP API.
+ *
+ * @since 7.0.0
+ *
+ * @param RequestInterface $request The PSR-7 request.
+ * @return string|null The request body.
+ */
+ private function prepare_body( RequestInterface $request ): ?string {
+ $body = $request->getBody();
+
+ if ( $body->getSize() === 0 ) {
+ return null;
+ }
+
+ if ( $body->isSeekable() ) {
+ $body->rewind();
+ }
+
+ return (string) $body;
+ }
+
+ /**
+ * Creates a PSR-7 response from a WordPress HTTP response.
+ *
+ * @since 7.0.0
+ *
+ * @param array $wp_response WordPress HTTP API response array.
+ * @return ResponseInterface PSR-7 response.
+ */
+ private function create_psr_response( array $wp_response ): ResponseInterface {
+ $status_code = wp_remote_retrieve_response_code( $wp_response );
+ $reason_phrase = wp_remote_retrieve_response_message( $wp_response );
+ $headers = wp_remote_retrieve_headers( $wp_response );
+ $body = wp_remote_retrieve_body( $wp_response );
+
+ $response = $this->response_factory->createResponse( (int) $status_code, $reason_phrase );
+
+ if ( $headers instanceof WP_HTTP_Requests_Response ) {
+ $headers = $headers->get_headers();
+ }
+
+ if ( is_array( $headers ) || $headers instanceof Traversable ) {
+ foreach ( $headers as $name => $value ) {
+ $response = $response->withHeader( $name, $value );
+ }
+ }
+
+ if ( ! empty( $body ) ) {
+ $stream = $this->stream_factory->createStream( $body );
+ $response = $response->withBody( $stream );
+ }
+
+ return $response;
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php
new file mode 100644
index 0000000000000..3f6669d84297c
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php
@@ -0,0 +1,115 @@
+}>
+ */
+ private $headers = array();
+
+ /**
+ * Request body.
+ *
+ * @since 7.0.0
+ * @var StreamInterface
+ */
+ private $body;
+
+ /**
+ * Explicit request target, if set.
+ *
+ * @since 7.0.0
+ * @var string|null
+ */
+ private $request_target;
+
+ /**
+ * Constructor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $method HTTP method.
+ * @param string|UriInterface $uri Request URI.
+ */
+ public function __construct( string $method, $uri ) {
+ $this->method = $method;
+ $this->uri = is_string( $uri ) ? new WP_AI_Client_PSR7_Uri( $uri ) : $uri;
+ $this->body = new WP_AI_Client_PSR7_Stream();
+
+ $host = $this->uri->getHost();
+ if ( '' !== $host && ! $this->hasHeader( 'Host' ) ) {
+ $this->set_header_internal( 'Host', $host );
+ }
+ }
+
+ /**
+ * Retrieves the HTTP protocol version.
+ *
+ * @since 7.0.0
+ *
+ * @return string HTTP protocol version.
+ */
+ public function getProtocolVersion(): string {
+ return $this->protocol_version;
+ }
+
+ /**
+ * Returns an instance with the specified HTTP protocol version.
+ *
+ * @since 7.0.0
+ *
+ * @param string $version HTTP protocol version.
+ * @return static
+ */
+ public function withProtocolVersion( string $version ): self {
+ $new = clone $this;
+ $new->protocol_version = $version;
+
+ return $new;
+ }
+
+ /**
+ * Retrieves all message header values.
+ *
+ * @since 7.0.0
+ *
+ * @return string[][] Associative array of headers.
+ */
+ public function getHeaders(): array {
+ $result = array();
+
+ foreach ( $this->headers as $entry ) {
+ $result[ $entry['name'] ] = $entry['values'];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return bool
+ */
+ public function hasHeader( string $name ): bool {
+ return isset( $this->headers[ strtolower( $name ) ] );
+ }
+
+ /**
+ * Retrieves a message header value by the given case-insensitive name.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string[] Header values.
+ */
+ public function getHeader( string $name ): array {
+ $normalized = strtolower( $name );
+
+ if ( ! isset( $this->headers[ $normalized ] ) ) {
+ return array();
+ }
+
+ return $this->headers[ $normalized ]['values'];
+ }
+
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string
+ */
+ public function getHeaderLine( string $name ): string {
+ return implode( ', ', $this->getHeader( $name ) );
+ }
+
+ /**
+ * Returns an instance with the provided value replacing the specified header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ */
+ public function withHeader( string $name, $value ): self {
+ $new = clone $this;
+ $new->set_header_internal( $name, $value );
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified header appended with the given value.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name to add.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ */
+ public function withAddedHeader( string $name, $value ): self {
+ $new = clone $this;
+ $normalized = strtolower( $name );
+ $values = is_array( $value ) ? $value : array( $value );
+
+ if ( isset( $new->headers[ $normalized ] ) ) {
+ $new->headers[ $normalized ]['values'] = array_merge(
+ $new->headers[ $normalized ]['values'],
+ $values
+ );
+ } else {
+ $new->headers[ $normalized ] = array(
+ 'name' => $name,
+ 'values' => $values,
+ );
+ }
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance without the specified header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name to remove.
+ * @return static
+ */
+ public function withoutHeader( string $name ): self {
+ $new = clone $this;
+ unset( $new->headers[ strtolower( $name ) ] );
+
+ return $new;
+ }
+
+ /**
+ * Gets the body of the message.
+ *
+ * @since 7.0.0
+ *
+ * @return StreamInterface
+ */
+ public function getBody(): StreamInterface {
+ return $this->body;
+ }
+
+ /**
+ * Returns an instance with the specified message body.
+ *
+ * @since 7.0.0
+ *
+ * @param StreamInterface $body Body.
+ * @return static
+ */
+ public function withBody( StreamInterface $body ): self {
+ $new = clone $this;
+ $new->body = $body;
+
+ return $new;
+ }
+
+ /**
+ * Retrieves the message's request target.
+ *
+ * @since 7.0.0
+ *
+ * @return string
+ */
+ public function getRequestTarget(): string {
+ if ( null !== $this->request_target ) {
+ return $this->request_target;
+ }
+
+ $target = $this->uri->getPath();
+
+ if ( '' === $target ) {
+ $target = '/';
+ }
+
+ $query = $this->uri->getQuery();
+
+ if ( '' !== $query ) {
+ $target .= '?' . $query;
+ }
+
+ return $target;
+ }
+
+ /**
+ * Returns an instance with the specific request-target.
+ *
+ * @since 7.0.0
+ *
+ * @param string $requestTarget Request target.
+ * @return static
+ */
+ public function withRequestTarget( string $requestTarget ): self {
+ $new = clone $this;
+ $new->request_target = $requestTarget;
+
+ return $new;
+ }
+
+ /**
+ * Retrieves the HTTP method of the request.
+ *
+ * @since 7.0.0
+ *
+ * @return string
+ */
+ public function getMethod(): string {
+ return $this->method;
+ }
+
+ /**
+ * Returns an instance with the provided HTTP method.
+ *
+ * @since 7.0.0
+ *
+ * @param string $method Case-sensitive method.
+ * @return static
+ */
+ public function withMethod( string $method ): self {
+ $new = clone $this;
+ $new->method = $method;
+
+ return $new;
+ }
+
+ /**
+ * Retrieves the URI instance.
+ *
+ * @since 7.0.0
+ *
+ * @return UriInterface
+ */
+ public function getUri(): UriInterface {
+ return $this->uri;
+ }
+
+ /**
+ * Returns an instance with the provided URI.
+ *
+ * @since 7.0.0
+ *
+ * @param UriInterface $uri New request URI to use.
+ * @param bool $preserveHost Preserve the original state of the Host header.
+ * @return static
+ */
+ public function withUri( UriInterface $uri, bool $preserveHost = false ): self {
+ $new = clone $this;
+ $new->uri = $uri;
+
+ $host = $uri->getHost();
+
+ if ( ! $preserveHost ) {
+ if ( '' !== $host ) {
+ $new->set_header_internal( 'Host', $host );
+ }
+ } elseif ( '' !== $host && ( ! $new->hasHeader( 'Host' ) || '' === $new->getHeaderLine( 'Host' ) ) ) {
+ $new->set_header_internal( 'Host', $host );
+ }
+
+ return $new;
+ }
+
+ /**
+ * Sets a header internally (mutating, for use in constructor and clone methods).
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Header name.
+ * @param string|string[] $value Header value(s).
+ */
+ private function set_header_internal( string $name, $value ): void {
+ $normalized = strtolower( $name );
+ $this->headers[ $normalized ] = array(
+ 'name' => $name,
+ 'values' => is_array( $value ) ? $value : array( $value ),
+ );
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php
new file mode 100644
index 0000000000000..eb84d2edd73ba
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php
@@ -0,0 +1,292 @@
+}>
+ */
+ private $headers = array();
+
+ /**
+ * Response body.
+ *
+ * @since 7.0.0
+ * @var StreamInterface
+ */
+ private $body;
+
+ /**
+ * Constructor.
+ *
+ * @since 7.0.0
+ *
+ * @param int $status_code HTTP status code.
+ * @param string $reason_phrase Reason phrase to associate with the status code.
+ */
+ public function __construct( int $status_code = 200, string $reason_phrase = '' ) {
+ $this->status_code = $status_code;
+ $this->reason_phrase = $reason_phrase;
+ $this->body = new WP_AI_Client_PSR7_Stream();
+ }
+
+ /**
+ * Gets the response status code.
+ *
+ * @since 7.0.0
+ *
+ * @return int Status code.
+ */
+ public function getStatusCode(): int {
+ return $this->status_code;
+ }
+
+ /**
+ * Returns an instance with the specified status code and reason phrase.
+ *
+ * @since 7.0.0
+ *
+ * @param int $code The 3-digit integer result code to set.
+ * @param string $reasonPhrase The reason phrase to use.
+ * @return static
+ */
+ public function withStatus( int $code, string $reasonPhrase = '' ): self {
+ $new = clone $this;
+ $new->status_code = $code;
+ $new->reason_phrase = $reasonPhrase;
+
+ return $new;
+ }
+
+ /**
+ * Gets the response reason phrase associated with the status code.
+ *
+ * @since 7.0.0
+ *
+ * @return string Reason phrase.
+ */
+ public function getReasonPhrase(): string {
+ return $this->reason_phrase;
+ }
+
+ /**
+ * Retrieves the HTTP protocol version.
+ *
+ * @since 7.0.0
+ *
+ * @return string HTTP protocol version.
+ */
+ public function getProtocolVersion(): string {
+ return $this->protocol_version;
+ }
+
+ /**
+ * Returns an instance with the specified HTTP protocol version.
+ *
+ * @since 7.0.0
+ *
+ * @param string $version HTTP protocol version.
+ * @return static
+ */
+ public function withProtocolVersion( string $version ): self {
+ $new = clone $this;
+ $new->protocol_version = $version;
+
+ return $new;
+ }
+
+ /**
+ * Retrieves all message header values.
+ *
+ * @since 7.0.0
+ *
+ * @return string[][] Associative array of headers.
+ */
+ public function getHeaders(): array {
+ $result = array();
+
+ foreach ( $this->headers as $entry ) {
+ $result[ $entry['name'] ] = $entry['values'];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return bool
+ */
+ public function hasHeader( string $name ): bool {
+ return isset( $this->headers[ strtolower( $name ) ] );
+ }
+
+ /**
+ * Retrieves a message header value by the given case-insensitive name.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string[] Header values.
+ */
+ public function getHeader( string $name ): array {
+ $normalized = strtolower( $name );
+
+ if ( ! isset( $this->headers[ $normalized ] ) ) {
+ return array();
+ }
+
+ return $this->headers[ $normalized ]['values'];
+ }
+
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string
+ */
+ public function getHeaderLine( string $name ): string {
+ return implode( ', ', $this->getHeader( $name ) );
+ }
+
+ /**
+ * Returns an instance with the provided value replacing the specified header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ */
+ public function withHeader( string $name, $value ): self {
+ $new = clone $this;
+ $normalized = strtolower( $name );
+ $new->headers[ $normalized ] = array(
+ 'name' => $name,
+ 'values' => is_array( $value ) ? $value : array( $value ),
+ );
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified header appended with the given value.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name to add.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ */
+ public function withAddedHeader( string $name, $value ): self {
+ $new = clone $this;
+ $normalized = strtolower( $name );
+ $values = is_array( $value ) ? $value : array( $value );
+
+ if ( isset( $new->headers[ $normalized ] ) ) {
+ $new->headers[ $normalized ]['values'] = array_merge(
+ $new->headers[ $normalized ]['values'],
+ $values
+ );
+ } else {
+ $new->headers[ $normalized ] = array(
+ 'name' => $name,
+ 'values' => $values,
+ );
+ }
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance without the specified header.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name Case-insensitive header field name to remove.
+ * @return static
+ */
+ public function withoutHeader( string $name ): self {
+ $new = clone $this;
+ unset( $new->headers[ strtolower( $name ) ] );
+
+ return $new;
+ }
+
+ /**
+ * Gets the body of the message.
+ *
+ * @since 7.0.0
+ *
+ * @return StreamInterface
+ */
+ public function getBody(): StreamInterface {
+ return $this->body;
+ }
+
+ /**
+ * Returns an instance with the specified message body.
+ *
+ * @since 7.0.0
+ *
+ * @param StreamInterface $body Body.
+ * @return static
+ */
+ public function withBody( StreamInterface $body ): self {
+ $new = clone $this;
+ $new->body = $body;
+
+ return $new;
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php
new file mode 100644
index 0000000000000..5ba6395e45754
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php
@@ -0,0 +1,243 @@
+content = $content;
+ }
+
+ /**
+ * Reads all data from the stream into a string.
+ *
+ * @since 7.0.0
+ *
+ * @return string
+ */
+ public function __toString(): string {
+ return $this->content;
+ }
+
+ /**
+ * Closes the stream. No-op for string-backed streams.
+ *
+ * @since 7.0.0
+ */
+ public function close(): void {
+ // No-op.
+ }
+
+ /**
+ * Separates any underlying resources from the stream.
+ *
+ * @since 7.0.0
+ *
+ * @return resource|null Always null for string-backed streams.
+ */
+ public function detach() {
+ return null;
+ }
+
+ /**
+ * Gets the size of the stream.
+ *
+ * @since 7.0.0
+ *
+ * @return int|null The size in bytes.
+ */
+ public function getSize(): ?int {
+ return strlen( $this->content );
+ }
+
+ /**
+ * Returns the current position of the read/write pointer.
+ *
+ * @since 7.0.0
+ *
+ * @return int Position of the pointer.
+ */
+ public function tell(): int {
+ return $this->offset;
+ }
+
+ /**
+ * Returns true if the stream is at the end.
+ *
+ * @since 7.0.0
+ *
+ * @return bool
+ */
+ public function eof(): bool {
+ return $this->offset >= strlen( $this->content );
+ }
+
+ /**
+ * Returns whether the stream is seekable.
+ *
+ * @since 7.0.0
+ *
+ * @return bool Always true.
+ */
+ public function isSeekable(): bool {
+ return true;
+ }
+
+ /**
+ * Seeks to a position in the stream.
+ *
+ * @since 7.0.0
+ *
+ * @param int $offset Stream offset.
+ * @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END.
+ */
+ public function seek( int $offset, int $whence = SEEK_SET ): void {
+ $length = strlen( $this->content );
+
+ switch ( $whence ) {
+ case SEEK_SET:
+ $this->offset = $offset;
+ break;
+ case SEEK_CUR:
+ $this->offset += $offset;
+ break;
+ case SEEK_END:
+ $this->offset = $length + $offset;
+ break;
+ }
+
+ if ( $this->offset < 0 ) {
+ $this->offset = 0;
+ }
+ }
+
+ /**
+ * Seeks to the beginning of the stream.
+ *
+ * @since 7.0.0
+ */
+ public function rewind(): void {
+ $this->offset = 0;
+ }
+
+ /**
+ * Returns whether the stream is writable.
+ *
+ * @since 7.0.0
+ *
+ * @return bool Always true.
+ */
+ public function isWritable(): bool {
+ return true;
+ }
+
+ /**
+ * Writes data to the stream.
+ *
+ * @since 7.0.0
+ *
+ * @param string $string The string to write.
+ * @return int Number of bytes written.
+ */
+ public function write( string $string ): int {
+ $this->content .= $string;
+ $length = strlen( $string );
+ $this->offset += $length;
+
+ return $length;
+ }
+
+ /**
+ * Returns whether the stream is readable.
+ *
+ * @since 7.0.0
+ *
+ * @return bool Always true.
+ */
+ public function isReadable(): bool {
+ return true;
+ }
+
+ /**
+ * Reads data from the stream.
+ *
+ * @since 7.0.0
+ *
+ * @param int $length Number of bytes to read.
+ * @return string Data read from the stream.
+ */
+ public function read( int $length ): string {
+ $data = substr( $this->content, $this->offset, $length );
+ $this->offset += strlen( $data );
+
+ return $data;
+ }
+
+ /**
+ * Returns the remaining contents of the stream.
+ *
+ * @since 7.0.0
+ *
+ * @return string
+ */
+ public function getContents(): string {
+ $remaining = substr( $this->content, $this->offset );
+ $this->offset = strlen( $this->content );
+
+ return $remaining;
+ }
+
+ /**
+ * Gets stream metadata.
+ *
+ * @since 7.0.0
+ *
+ * @param string|null $key Specific metadata to retrieve.
+ * @return array|mixed|null Returns null for specific keys, empty array otherwise.
+ */
+ public function getMetadata( ?string $key = null ) {
+ if ( null !== $key ) {
+ return null;
+ }
+
+ return array();
+ }
+}
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php
new file mode 100644
index 0000000000000..58dfb364d469b
--- /dev/null
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php
@@ -0,0 +1,389 @@
+
+ */
+ private static $default_ports = array(
+ 'http' => 80,
+ 'https' => 443,
+ );
+
+ /**
+ * URI scheme (e.g. "http", "https").
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $scheme = '';
+
+ /**
+ * URI user info (e.g. "user:password").
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $user_info = '';
+
+ /**
+ * URI host.
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $host = '';
+
+ /**
+ * URI port.
+ *
+ * @since 7.0.0
+ * @var int|null
+ */
+ private $port;
+
+ /**
+ * URI path.
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $path = '';
+
+ /**
+ * URI query string.
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $query = '';
+
+ /**
+ * URI fragment.
+ *
+ * @since 7.0.0
+ * @var string
+ */
+ private $fragment = '';
+
+ /**
+ * Constructor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $uri URI string to parse.
+ */
+ public function __construct( string $uri = '' ) {
+ if ( '' !== $uri ) {
+ $parts = wp_parse_url( $uri );
+
+ if ( false !== $parts ) {
+ $this->scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
+ $this->host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
+ $this->port = isset( $parts['port'] ) ? (int) $parts['port'] : null;
+ $this->path = $parts['path'] ?? '';
+ $this->query = $parts['query'] ?? '';
+
+ $this->fragment = $parts['fragment'] ?? '';
+
+ if ( isset( $parts['user'] ) ) {
+ $this->user_info = $parts['user'];
+ if ( isset( $parts['pass'] ) ) {
+ $this->user_info .= ':' . $parts['pass'];
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieves the scheme component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI scheme.
+ */
+ public function getScheme(): string {
+ return $this->scheme;
+ }
+
+ /**
+ * Retrieves the authority component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI authority, in "[user-info@]host[:port]" format.
+ */
+ public function getAuthority(): string {
+ if ( '' === $this->host ) {
+ return '';
+ }
+
+ $authority = $this->host;
+
+ if ( '' !== $this->user_info ) {
+ $authority = $this->user_info . '@' . $authority;
+ }
+
+ if ( null !== $this->port && ! $this->is_standard_port() ) {
+ $authority .= ':' . $this->port;
+ }
+
+ return $authority;
+ }
+
+ /**
+ * Retrieves the user information component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI user information.
+ */
+ public function getUserInfo(): string {
+ return $this->user_info;
+ }
+
+ /**
+ * Retrieves the host component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI host.
+ */
+ public function getHost(): string {
+ return $this->host;
+ }
+
+ /**
+ * Retrieves the port component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return int|null The URI port, or null if standard or not set.
+ */
+ public function getPort(): ?int {
+ if ( $this->is_standard_port() ) {
+ return null;
+ }
+
+ return $this->port;
+ }
+
+ /**
+ * Retrieves the path component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI path.
+ */
+ public function getPath(): string {
+ return $this->path;
+ }
+
+ /**
+ * Retrieves the query string of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI query string.
+ */
+ public function getQuery(): string {
+ return $this->query;
+ }
+
+ /**
+ * Retrieves the fragment component of the URI.
+ *
+ * @since 7.0.0
+ *
+ * @return string The URI fragment.
+ */
+ public function getFragment(): string {
+ return $this->fragment;
+ }
+
+ /**
+ * Returns an instance with the specified scheme.
+ *
+ * @since 7.0.0
+ *
+ * @param string $scheme The scheme to use with the new instance.
+ * @return static A new instance with the specified scheme.
+ */
+ public function withScheme( string $scheme ): UriInterface {
+ $new = clone $this;
+ $new->scheme = strtolower( $scheme );
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified user information.
+ *
+ * @since 7.0.0
+ *
+ * @param string $user The user name to use for authority.
+ * @param string|null $password The password associated with $user.
+ * @return static A new instance with the specified user information.
+ */
+ public function withUserInfo( string $user, ?string $password = null ): UriInterface {
+ $new = clone $this;
+ $new->user_info = $user;
+
+ if ( null !== $password && '' !== $password ) {
+ $new->user_info .= ':' . $password;
+ }
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified host.
+ *
+ * @since 7.0.0
+ *
+ * @param string $host The hostname to use with the new instance.
+ * @return static A new instance with the specified host.
+ */
+ public function withHost( string $host ): UriInterface {
+ $new = clone $this;
+ $new->host = strtolower( $host );
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified port.
+ *
+ * @since 7.0.0
+ *
+ * @param int|null $port The port to use with the new instance.
+ * @return static A new instance with the specified port.
+ */
+ public function withPort( ?int $port ): UriInterface {
+ $new = clone $this;
+ $new->port = $port;
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified path.
+ *
+ * @since 7.0.0
+ *
+ * @param string $path The path to use with the new instance.
+ * @return static A new instance with the specified path.
+ */
+ public function withPath( string $path ): UriInterface {
+ $new = clone $this;
+ $new->path = $path;
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified query string.
+ *
+ * @since 7.0.0
+ *
+ * @param string $query The query string to use with the new instance.
+ * @return static A new instance with the specified query string.
+ */
+ public function withQuery( string $query ): UriInterface {
+ $new = clone $this;
+ $new->query = $query;
+
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the specified URI fragment.
+ *
+ * @since 7.0.0
+ *
+ * @param string $fragment The fragment to use with the new instance.
+ * @return static A new instance with the specified fragment.
+ */
+ public function withFragment( string $fragment ): UriInterface {
+ $new = clone $this;
+ $new->fragment = $fragment;
+
+ return $new;
+ }
+
+ /**
+ * Returns the string representation as a URI reference.
+ *
+ * @since 7.0.0
+ *
+ * @return string
+ */
+ public function __toString(): string {
+ $uri = '';
+ $authority = $this->getAuthority();
+
+ if ( '' !== $this->scheme ) {
+ $uri .= $this->scheme . ':';
+ }
+
+ if ( '' !== $authority ) {
+ $uri .= '//' . $authority;
+ }
+
+ $path = $this->path;
+
+ if ( '' !== $authority && ( '' === $path || '/' !== $path[0] ) ) {
+ $path = '/' . $path;
+ } elseif ( '' === $authority && str_starts_with( $path, '//' ) ) {
+ $path = '/' . ltrim( $path, '/' );
+ }
+
+ $uri .= $path;
+
+ if ( '' !== $this->query ) {
+ $uri .= '?' . $this->query;
+ }
+
+ if ( '' !== $this->fragment ) {
+ $uri .= '#' . $this->fragment;
+ }
+
+ return $uri;
+ }
+
+ /**
+ * Checks whether the current port is the standard port for the scheme.
+ *
+ * @since 7.0.0
+ *
+ * @return bool True if port is the standard port for the current scheme.
+ */
+ private function is_standard_port(): bool {
+ if ( null === $this->port ) {
+ return false;
+ }
+
+ return isset( self::$default_ports[ $this->scheme ] )
+ && self::$default_ports[ $this->scheme ] === $this->port;
+ }
+}
diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php
new file mode 100644
index 0000000000000..1ceccbbb35d77
--- /dev/null
+++ b/src/wp-includes/ai-client.php
@@ -0,0 +1,22 @@
+ $schema) Sets the output schema.
+ * @method self as_output_modalities(ModalityEnum ...$modalities) Sets the output modalities.
+ * @method self as_output_file_type(FileTypeEnum $fileType) Sets the output file type.
+ * @method self as_json_response(?array $schema = null) Configures the prompt for JSON response output.
+ * @method bool|WP_Error is_supported(?CapabilityEnum $capability = null) Checks if the prompt is supported for the given capability.
+ * @method bool is_supported_for_text_generation() Checks if the prompt is supported for text generation.
+ * @method bool is_supported_for_image_generation() Checks if the prompt is supported for image generation.
+ * @method bool is_supported_for_text_to_speech_conversion() Checks if the prompt is supported for text to speech conversion.
+ * @method bool is_supported_for_video_generation() Checks if the prompt is supported for video generation.
+ * @method bool is_supported_for_speech_generation() Checks if the prompt is supported for speech generation.
+ * @method bool is_supported_for_music_generation() Checks if the prompt is supported for music generation.
+ * @method bool is_supported_for_embedding_generation() Checks if the prompt is supported for embedding generation.
+ * @method GenerativeAiResult|WP_Error generate_result(?CapabilityEnum $capability = null) Generates a result from the prompt.
+ * @method GenerativeAiResult|WP_Error generate_text_result() Generates a text result from the prompt.
+ * @method GenerativeAiResult|WP_Error generate_image_result() Generates an image result from the prompt.
+ * @method GenerativeAiResult|WP_Error generate_speech_result() Generates a speech result from the prompt.
+ * @method GenerativeAiResult|WP_Error convert_text_to_speech_result() Converts text to speech and returns the result.
+ * @method string|WP_Error generate_text() Generates text from the prompt.
+ * @method list|WP_Error generate_texts(?int $candidateCount = null) Generates multiple text candidates from the prompt.
+ * @method File|WP_Error generate_image() Generates an image from the prompt.
+ * @method list|WP_Error generate_images(?int $candidateCount = null) Generates multiple images from the prompt.
+ * @method File|WP_Error convert_text_to_speech() Converts text to speech.
+ * @method list|WP_Error convert_text_to_speeches(?int $candidateCount = null) Converts text to multiple speech outputs.
+ * @method File|WP_Error generate_speech() Generates speech from the prompt.
+ * @method list|WP_Error generate_speeches(?int $candidateCount = null) Generates multiple speech outputs 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;
+
+ /**
+ * List of methods that generate a result from the prompt.
+ *
+ * Structured as a map for faster lookups.
+ *
+ * @since 7.0.0
+ * @var array
+ */
+ private static array $generating_methods = array(
+ 'generate_result' => true,
+ 'generate_text_result' => true,
+ 'generate_image_result' => true,
+ 'generate_speech_result' => true,
+ 'convert_text_to_speech_result' => true,
+ 'generate_text' => true,
+ 'generate_texts' => true,
+ 'generate_image' => true,
+ 'generate_images' => true,
+ 'convert_text_to_speech' => true,
+ 'convert_text_to_speeches' => true,
+ 'generate_speech' => true,
+ 'generate_speeches' => true,
+ );
+
+ /**
+ * List of methods that check whether the prompt is supported.
+ *
+ * Structured as a map for faster lookups.
+ *
+ * @since 7.0.0
+ * @var array
+ */
+ private static array $support_check_methods = array(
+ 'is_supported' => true,
+ 'is_supported_for_text_generation' => true,
+ 'is_supported_for_image_generation' => true,
+ 'is_supported_for_text_to_speech_conversion' => true,
+ 'is_supported_for_video_generation' => true,
+ 'is_supported_for_speech_generation' => true,
+ 'is_supported_for_music_generation' => true,
+ 'is_supported_for_embedding_generation' => true,
+ );
+
+ /**
+ * Constructor.
+ *
+ * @since 7.0.0
+ *
+ * @param ProviderRegistry $registry The provider registry for finding suitable models.
+ * @param mixed $prompt Optional initial prompt content.
+ */
+ public function __construct( ProviderRegistry $registry, $prompt = null ) {
+ $this->builder = new PromptBuilder( $registry, $prompt );
+
+ /**
+ * Filters the default request timeout in seconds for AI Client HTTP requests.
+ *
+ * @since 7.0.0
+ *
+ * @param int $default_timeout The default timeout in seconds.
+ */
+ $default_timeout = (int) apply_filters( 'wp_ai_client_default_request_timeout', 30 );
+
+ $this->builder->usingRequestOptions(
+ RequestOptions::fromArray(
+ array(
+ RequestOptions::KEY_TIMEOUT => $default_timeout,
+ )
+ )
+ );
+ }
+
+ /**
+ * Registers WordPress abilities as function declarations for the AI model.
+ *
+ * Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix
+ * naming convention and passes them to the underlying prompt builder.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_Ability|string ...$abilities The abilities to register, either as WP_Ability objects or ability name strings.
+ * @return self The current instance for method chaining.
+ */
+ public function using_abilities( ...$abilities ): self {
+ $declarations = array();
+
+ foreach ( $abilities as $ability ) {
+ if ( is_string( $ability ) ) {
+ $ability = wp_get_ability( $ability );
+ }
+
+ if ( ! $ability instanceof WP_Ability ) {
+ continue;
+ }
+
+ $function_name = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $ability->get_name() );
+ $input_schema = $ability->get_input_schema();
+
+ $declarations[] = new FunctionDeclaration(
+ $function_name,
+ $ability->get_description(),
+ ! empty( $input_schema ) ? $input_schema : null
+ );
+ }
+
+ if ( ! empty( $declarations ) ) {
+ return $this->using_function_declarations( ...$declarations );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts.
+ *
+ * This allows WordPress developers to use snake_case naming conventions. It catches
+ * any exceptions thrown, stores them, and returns a WP_Error when a terminate method
+ * is called.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name The method name in snake_case.
+ * @param array $arguments The method arguments.
+ * @return mixed The result of the method call.
+ */
+ public function __call( string $name, array $arguments ) {
+ /*
+ * If an error occurred in a previous method call, either return the error for terminate methods,
+ * or return the same instance for other methods to maintain the fluent interface.
+ */
+ if ( null !== $this->error ) {
+ if ( self::is_generating_method( $name ) ) {
+ return $this->error;
+ }
+ if ( self::is_support_check_method( $name ) ) {
+ return false;
+ }
+ return $this;
+ }
+
+ // Check if the 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 ) ) {
+ /**
+ * 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;
+ }
+
+ // For generate_* and convert_text_to_speech* methods, create a WP_Error.
+ $this->error = new WP_Error(
+ 'prompt_prevented',
+ 'Prompt execution was prevented by a filter.',
+ array(
+ 'exception_class' => 'WP_AI_Client_Prompt_Prevented',
+ )
+ );
+
+ if ( self::is_generating_method( $name ) ) {
+ return $this->error;
+ }
+ return $this;
+ }
+ }
+
+ try {
+ $callable = $this->get_builder_callable( $name );
+ $result = $callable( ...$arguments );
+
+ // If the result is a PromptBuilder, return the current instance to allow method chaining.
+ if ( $result instanceof PromptBuilder ) {
+ return $this;
+ }
+
+ return $result;
+ } catch ( Exception $e ) {
+ $this->error = new WP_Error(
+ 'prompt_builder_error',
+ $e->getMessage(),
+ array(
+ 'exception_class' => get_class( $e ),
+ )
+ );
+
+ if ( self::is_generating_method( $name ) ) {
+ return $this->error;
+ }
+ return $this;
+ }
+ }
+
+ /**
+ * Checks if a method name is a support check method (is_supported*).
+ *
+ * @since 7.0.0
+ *
+ * @param string $name The method name.
+ * @return bool True if the method is a support check method, false otherwise.
+ */
+ private static function is_support_check_method( string $name ): bool {
+ return isset( self::$support_check_methods[ $name ] );
+ }
+
+ /**
+ * Checks if a method name is a generating method (generate_*, convert_text_to_speech*).
+ *
+ * @since 7.0.0
+ *
+ * @param string $name The method name.
+ * @return bool True if the method is a generating method, false otherwise.
+ */
+ private static function is_generating_method( string $name ): bool {
+ return isset( self::$generating_methods[ $name ] );
+ }
+
+ /**
+ * Retrieves a callable for a given PHP AI Client SDK prompt builder method name.
+ *
+ * @since 7.0.0
+ *
+ * @param string $name The method name in snake_case.
+ * @return callable The callable for the specified method.
+ *
+ * @throws BadMethodCallException If the method does not exist.
+ */
+ protected function get_builder_callable( string $name ): callable {
+ $camel_case_name = $this->snake_to_camel_case( $name );
+
+ if ( ! is_callable( array( $this->builder, $camel_case_name ) ) ) {
+ throw new BadMethodCallException(
+ sprintf(
+ 'Method %s does not exist on %s',
+ $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ )
+ );
+ }
+
+ return array( $this->builder, $camel_case_name );
+ }
+
+ /**
+ * Converts snake_case to camelCase.
+ *
+ * @since 7.0.0
+ *
+ * @param string $snake_case The snake_case string.
+ * @return string The camelCase string.
+ */
+ private function snake_to_camel_case( string $snake_case ): string {
+ $parts = explode( '_', $snake_case );
+
+ $camel_case = $parts[0];
+ $parts_count = count( $parts );
+ for ( $i = 1; $i < $parts_count; $i++ ) {
+ $camel_case .= ucfirst( $parts[ $i ] );
+ }
+
+ return $camel_case;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php
new file mode 100644
index 0000000000000..b4305ff4c7ed8
--- /dev/null
+++ b/src/wp-includes/php-ai-client/autoload.php
@@ -0,0 +1,48 @@
+getProvider('openai')->getModel('gpt-4');
+ * $result = AiClient::generateTextResult('What is PHP?', $model);
+ * ```
+ *
+ * ### 2. ModelConfig for Auto-Discovery
+ * Use ModelConfig to specify requirements and let the system discover the best model:
+ * ```php
+ * $config = new ModelConfig();
+ * $config->setTemperature(0.7);
+ * $config->setMaxTokens(150);
+ *
+ * $result = AiClient::generateTextResult('What is PHP?', $config);
+ * ```
+ *
+ * ### 3. Automatic Discovery (Default)
+ * Pass null or omit the parameter for intelligent model discovery based on prompt content:
+ * ```php
+ * // System analyzes prompt and selects appropriate model automatically
+ * $result = AiClient::generateTextResult('What is PHP?');
+ * $imageResult = AiClient::generateImageResult('A sunset over mountains');
+ * ```
+ *
+ * ## Fluent API Examples
+ * ```php
+ * // Fluent API with automatic model discovery
+ * $result = AiClient::prompt('Generate an image of a sunset')
+ * ->usingTemperature(0.7)
+ * ->generateImageResult();
+ *
+ * // Fluent API with specific model
+ * $result = AiClient::prompt('What is PHP?')
+ * ->usingModel($specificModel)
+ * ->usingTemperature(0.5)
+ * ->generateTextResult();
+ *
+ * // Fluent API with model configuration
+ * $result = AiClient::prompt('Explain quantum physics')
+ * ->usingModelConfig($config)
+ * ->generateTextResult();
+ * ```
+ *
+ * @since 0.1.0
+ *
+ * @phpstan-import-type Prompt from PromptBuilder
+ *
+ * phpcs:ignore Generic.Files.LineLength.TooLong
+ */
+class AiClient
+{
+ /**
+ * @var string The version of the AI Client.
+ */
+ public const VERSION = '0.4.1';
+ /**
+ * @var ProviderRegistry|null The default provider registry instance.
+ */
+ private static ?ProviderRegistry $defaultRegistry = null;
+ /**
+ * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events.
+ */
+ private static ?EventDispatcherInterface $eventDispatcher = null;
+ /**
+ * @var CacheInterface|null The PSR-16 cache for storing and retrieving cached data.
+ */
+ private static ?CacheInterface $cache = null;
+ /**
+ * Gets the default provider registry instance.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderRegistry The default provider registry.
+ */
+ public static function defaultRegistry(): ProviderRegistry
+ {
+ if (self::$defaultRegistry === null) {
+ self::$defaultRegistry = new ProviderRegistry();
+ }
+ return self::$defaultRegistry;
+ }
+ /**
+ * Sets the event dispatcher for prompt lifecycle events.
+ *
+ * The event dispatcher will be used to dispatch BeforeGenerateResultEvent and
+ * AfterGenerateResultEvent during prompt generation.
+ *
+ * @since 0.4.0
+ *
+ * @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable.
+ * @return void
+ */
+ public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void
+ {
+ self::$eventDispatcher = $dispatcher;
+ }
+ /**
+ * Gets the event dispatcher for prompt lifecycle events.
+ *
+ * @since 0.4.0
+ *
+ * @return EventDispatcherInterface|null The event dispatcher, or null if not set.
+ */
+ public static function getEventDispatcher(): ?EventDispatcherInterface
+ {
+ return self::$eventDispatcher;
+ }
+ /**
+ * Sets the PSR-16 cache for storing and retrieving cached data.
+ *
+ * The cache can be used to store AI responses and other data to avoid
+ * redundant API calls and improve performance.
+ *
+ * @since 0.4.0
+ *
+ * @param CacheInterface|null $cache The PSR-16 cache instance, or null to disable caching.
+ * @return void
+ */
+ public static function setCache(?CacheInterface $cache): void
+ {
+ self::$cache = $cache;
+ }
+ /**
+ * Gets the PSR-16 cache instance.
+ *
+ * @since 0.4.0
+ *
+ * @return CacheInterface|null The cache instance, or null if not set.
+ */
+ public static function getCache(): ?CacheInterface
+ {
+ return self::$cache;
+ }
+ /**
+ * Checks if a provider is configured and available for use.
+ *
+ * Supports multiple input formats for developer convenience:
+ * - ProviderAvailabilityInterface: Direct availability check
+ * - string (provider ID): e.g., AiClient::isConfigured('openai')
+ * - string (class name): e.g., AiClient::isConfigured(OpenAiProvider::class)
+ *
+ * When using string input, this method leverages the ProviderRegistry's centralized
+ * dependency management, ensuring HttpTransporter and authentication are properly
+ * injected into availability instances.
+ *
+ * @since 0.1.0
+ * @since 0.2.0 Now supports being passed a provider ID or class name.
+ *
+ * @param ProviderAvailabilityInterface|string|class-string $availabilityOrIdOrClassName
+ * The provider availability instance, provider ID, or provider class name.
+ * @return bool True if the provider is configured and available, false otherwise.
+ */
+ public static function isConfigured($availabilityOrIdOrClassName): bool
+ {
+ // Handle direct ProviderAvailabilityInterface (backward compatibility)
+ if ($availabilityOrIdOrClassName instanceof ProviderAvailabilityInterface) {
+ return $availabilityOrIdOrClassName->isConfigured();
+ }
+ // Handle string input (provider ID or class name) via registry
+ if (is_string($availabilityOrIdOrClassName)) {
+ return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName);
+ }
+ throw new \InvalidArgumentException('Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' . sprintf('Received: %s', is_object($availabilityOrIdOrClassName) ? get_class($availabilityOrIdOrClassName) : gettype($availabilityOrIdOrClassName)));
+ }
+ /**
+ * Creates a new prompt builder for fluent API usage.
+ *
+ * Returns a PromptBuilder instance configured with the specified or default registry.
+ * The traditional API methods in this class delegate to PromptBuilder
+ * for all generation logic.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt Optional initial prompt content.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return PromptBuilder The prompt builder instance.
+ */
+ public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder
+ {
+ return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher);
+ }
+ /**
+ * Generates content using a unified API that automatically detects model capabilities.
+ *
+ * When no model is provided, this method delegates to PromptBuilder for intelligent
+ * model discovery based on prompt content and configuration. When a model is provided,
+ * it infers the capability from the model's interfaces and delegates to the capability-based method.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig $modelOrConfig Specific model to use, or model configuration
+ * for auto-discovery.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the provided model doesn't support any known generation type.
+ * @throws \RuntimeException If no suitable model can be found for the prompt.
+ */
+ public static function generateResult($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult();
+ }
+ /**
+ * Generates text using the traditional API approach.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
+ * or model configuration for auto-discovery,
+ * or null for defaults.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the prompt format is invalid.
+ * @throws \RuntimeException If no suitable model is found.
+ */
+ public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult();
+ }
+ /**
+ * Generates an image using the traditional API approach.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
+ * or model configuration for auto-discovery,
+ * or null for defaults.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the prompt format is invalid.
+ * @throws \RuntimeException If no suitable model is found.
+ */
+ public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult();
+ }
+ /**
+ * Converts text to speech using the traditional API approach.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
+ * or model configuration for auto-discovery,
+ * or null for defaults.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the prompt format is invalid.
+ * @throws \RuntimeException If no suitable model is found.
+ */
+ public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult();
+ }
+ /**
+ * Generates speech using the traditional API approach.
+ *
+ * @since 0.1.0
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
+ * or model configuration for auto-discovery,
+ * or null for defaults.
+ * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
+ * @return GenerativeAiResult The generation result.
+ *
+ * @throws \InvalidArgumentException If the prompt format is invalid.
+ * @throws \RuntimeException If no suitable model is found.
+ */
+ public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
+ {
+ self::validateModelOrConfigParameter($modelOrConfig);
+ return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult();
+ }
+ /**
+ * Creates a new message builder for fluent API usage.
+ *
+ * This method will be implemented once MessageBuilder is available.
+ * MessageBuilder will provide a fluent interface for constructing complex
+ * messages with multiple parts, attachments, and metadata.
+ *
+ * @since 0.1.0
+ *
+ * @param string|null $text Optional initial message text.
+ * @return object MessageBuilder instance (type will be updated when MessageBuilder is available).
+ *
+ * @throws \RuntimeException When MessageBuilder is not yet available.
+ */
+ public static function message(?string $text = null)
+ {
+ throw new RuntimeException('MessageBuilder is not yet available. This method depends on builder infrastructure. ' . 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.');
+ }
+ /**
+ * Validates that parameter is ModelInterface, ModelConfig, or null.
+ *
+ * @param mixed $modelOrConfig The parameter to validate.
+ * @return void
+ * @throws \InvalidArgumentException If parameter is invalid type.
+ */
+ private static function validateModelOrConfigParameter($modelOrConfig): void
+ {
+ if ($modelOrConfig !== null && !$modelOrConfig instanceof ModelInterface && !$modelOrConfig instanceof ModelConfig) {
+ throw new InvalidArgumentException('Parameter must be a ModelInterface instance (specific model), ' . 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)));
+ }
+ }
+ /**
+ * Configures PromptBuilder based on model/config parameter type.
+ *
+ * @param Prompt $prompt The prompt content.
+ * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter.
+ * @param ProviderRegistry|null $registry Optional custom registry to use.
+ * @return PromptBuilder Configured prompt builder.
+ */
+ private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder
+ {
+ $builder = self::prompt($prompt, $registry);
+ if ($modelOrConfig instanceof ModelInterface) {
+ $builder->usingModel($modelOrConfig);
+ } elseif ($modelOrConfig instanceof ModelConfig) {
+ $builder->usingModelConfig($modelOrConfig);
+ }
+ // null case: use default model discovery
+ return $builder;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php b/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php
new file mode 100644
index 0000000000000..cc02f77e75d5b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php
@@ -0,0 +1,203 @@
+ The parts that make up the message.
+ */
+ protected array $parts = [];
+ /**
+ * Constructor.
+ *
+ * @since 0.2.0
+ *
+ * @param Input $input Optional initial content.
+ * @param MessageRoleEnum|null $role Optional role.
+ */
+ public function __construct($input = null, ?MessageRoleEnum $role = null)
+ {
+ $this->role = $role;
+ if ($input === null) {
+ return;
+ }
+ // Handle different input types
+ if ($input instanceof MessagePart) {
+ $this->parts[] = $input;
+ } elseif (is_string($input)) {
+ $this->withText($input);
+ } elseif ($input instanceof File) {
+ $this->withFile($input);
+ } elseif ($input instanceof FunctionCall) {
+ $this->withFunctionCall($input);
+ } elseif ($input instanceof FunctionResponse) {
+ $this->withFunctionResponse($input);
+ } elseif (is_array($input) && MessagePart::isArrayShape($input)) {
+ $this->parts[] = MessagePart::fromArray($input);
+ } else {
+ throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.');
+ }
+ }
+ /**
+ * Sets the role of the message sender.
+ *
+ * @since 0.2.0
+ *
+ * @param MessageRoleEnum $role The role to set.
+ * @return self
+ */
+ public function usingRole(MessageRoleEnum $role): self
+ {
+ $this->role = $role;
+ return $this;
+ }
+ /**
+ * Sets the role to user.
+ *
+ * @since 0.2.0
+ *
+ * @return self
+ */
+ public function usingUserRole(): self
+ {
+ return $this->usingRole(MessageRoleEnum::user());
+ }
+ /**
+ * Sets the role to model.
+ *
+ * @since 0.2.0
+ *
+ * @return self
+ */
+ public function usingModelRole(): self
+ {
+ return $this->usingRole(MessageRoleEnum::model());
+ }
+ /**
+ * Adds text content to the message.
+ *
+ * @since 0.2.0
+ *
+ * @param string $text The text to add.
+ * @return self
+ * @throws InvalidArgumentException If the text is empty.
+ */
+ public function withText(string $text): self
+ {
+ if (trim($text) === '') {
+ throw new InvalidArgumentException('Text content cannot be empty.');
+ }
+ $this->parts[] = new MessagePart($text);
+ return $this;
+ }
+ /**
+ * Adds a file to the message.
+ *
+ * Accepts:
+ * - File object
+ * - URL string (remote file)
+ * - Base64-encoded data string
+ * - Data URI string (data:mime/type;base64,data)
+ * - Local file path string
+ *
+ * @since 0.2.0
+ *
+ * @param string|File $file The file to add.
+ * @param string|null $mimeType Optional MIME type (ignored if File object provided).
+ * @return self
+ * @throws InvalidArgumentException If the file is invalid.
+ */
+ public function withFile($file, ?string $mimeType = null): self
+ {
+ $file = $file instanceof File ? $file : new File($file, $mimeType);
+ $this->parts[] = new MessagePart($file);
+ return $this;
+ }
+ /**
+ * Adds a function call to the message.
+ *
+ * @since 0.2.0
+ *
+ * @param FunctionCall $functionCall The function call to add.
+ * @return self
+ */
+ public function withFunctionCall(FunctionCall $functionCall): self
+ {
+ $this->parts[] = new MessagePart($functionCall);
+ return $this;
+ }
+ /**
+ * Adds a function response to the message.
+ *
+ * @since 0.2.0
+ *
+ * @param FunctionResponse $functionResponse The function response to add.
+ * @return self
+ */
+ public function withFunctionResponse(FunctionResponse $functionResponse): self
+ {
+ $this->parts[] = new MessagePart($functionResponse);
+ return $this;
+ }
+ /**
+ * Adds multiple message parts to the message.
+ *
+ * @since 0.2.0
+ *
+ * @param MessagePart ...$parts The message parts to add.
+ * @return self
+ */
+ public function withMessageParts(MessagePart ...$parts): self
+ {
+ foreach ($parts as $part) {
+ $this->parts[] = $part;
+ }
+ return $this;
+ }
+ /**
+ * Builds and returns the Message object.
+ *
+ * @since 0.2.0
+ *
+ * @return Message The built message.
+ * @throws InvalidArgumentException If the message validation fails.
+ */
+ public function get(): Message
+ {
+ if (empty($this->parts)) {
+ throw new InvalidArgumentException('Cannot build an empty message. Add content using withText() or similar methods.');
+ }
+ if ($this->role === null) {
+ throw new InvalidArgumentException('Cannot build a message with no role. Set a role using usingRole() or similar methods.');
+ }
+ // At this point, we've validated that $this->role is not null
+ /** @var MessageRoleEnum $role */
+ $role = $this->role;
+ return new Message($role, $this->parts);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
new file mode 100644
index 0000000000000..6821b99280bd3
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
@@ -0,0 +1,1343 @@
+|list|null
+ */
+class PromptBuilder
+{
+ /**
+ * @var ProviderRegistry The provider registry for finding suitable models.
+ */
+ private ProviderRegistry $registry;
+ /**
+ * @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.
+ */
+ private ?EventDispatcherInterface $eventDispatcher = null;
+ // phpcs:disable Generic.Files.LineLength.TooLong
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param ProviderRegistry $registry The provider registry for finding suitable models.
+ * @param Prompt $prompt Optional initial prompt content.
+ * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events.
+ */
+ // phpcs:enable Generic.Files.LineLength.TooLong
+ public function __construct(ProviderRegistry $registry, $prompt = null, ?EventDispatcherInterface $eventDispatcher = null)
+ {
+ $this->registry = $registry;
+ $this->modelConfig = new ModelConfig();
+ $this->eventDispatcher = $eventDispatcher;
+ if ($prompt === null) {
+ return;
+ }
+ // Check if it's a list of Messages - set as messages
+ if ($this->isMessagesList($prompt)) {
+ $this->messages = $prompt;
+ return;
+ }
+ // Parse it as a user message
+ $userMessage = $this->parseMessage($prompt, MessageRoleEnum::user());
+ $this->messages[] = $userMessage;
+ }
+ /**
+ * Adds text to the current message.
+ *
+ * @since 0.1.0
+ *
+ * @param string $text The text to add.
+ * @return self
+ */
+ public function withText(string $text): self
+ {
+ $part = new MessagePart($text);
+ $this->appendPartToMessages($part);
+ return $this;
+ }
+ /**
+ * Adds a file to the current message.
+ *
+ * Accepts:
+ * - File object
+ * - URL string (remote file)
+ * - Base64-encoded data string
+ * - Data URI string (data:mime/type;base64,data)
+ * - Local file path string
+ *
+ * @since 0.1.0
+ *
+ * @param string|File $file The file (File object or string representation).
+ * @param string|null $mimeType The MIME type (optional, ignored if File object provided).
+ * @return self
+ * @throws InvalidArgumentException If the file is invalid or MIME type cannot be determined.
+ */
+ public function withFile($file, ?string $mimeType = null): self
+ {
+ $file = $file instanceof File ? $file : new File($file, $mimeType);
+ $part = new MessagePart($file);
+ $this->appendPartToMessages($part);
+ return $this;
+ }
+ /**
+ * Adds a function response to the current message.
+ *
+ * @since 0.1.0
+ *
+ * @param FunctionResponse $functionResponse The function response.
+ * @return self
+ */
+ public function withFunctionResponse(FunctionResponse $functionResponse): self
+ {
+ $part = new MessagePart($functionResponse);
+ $this->appendPartToMessages($part);
+ return $this;
+ }
+ /**
+ * Adds message parts to the current message.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart ...$parts The message parts to add.
+ * @return self
+ */
+ public function withMessageParts(MessagePart ...$parts): self
+ {
+ foreach ($parts as $part) {
+ $this->appendPartToMessages($part);
+ }
+ return $this;
+ }
+ /**
+ * Adds conversation history messages.
+ *
+ * Historical messages are prepended to the beginning of the message list,
+ * before the current message being built.
+ *
+ * @since 0.1.0
+ *
+ * @param Message ...$messages The messages to add to history.
+ * @return self
+ */
+ public function withHistory(Message ...$messages): self
+ {
+ // Prepend the history messages to the beginning of the messages array
+ $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 [model ID, provider ID] tuples.
+ * @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.
+ *
+ * System instructions are stored in the model configuration and guide
+ * the AI model's behavior throughout the conversation.
+ *
+ * @since 0.1.0
+ *
+ * @param string $systemInstruction The system instruction text.
+ * @return self
+ */
+ public function usingSystemInstruction(string $systemInstruction): self
+ {
+ $this->modelConfig->setSystemInstruction($systemInstruction);
+ return $this;
+ }
+ /**
+ * Sets the maximum number of tokens to generate.
+ *
+ * @since 0.1.0
+ *
+ * @param int $maxTokens The maximum number of tokens.
+ * @return self
+ */
+ public function usingMaxTokens(int $maxTokens): self
+ {
+ $this->modelConfig->setMaxTokens($maxTokens);
+ return $this;
+ }
+ /**
+ * Sets the temperature for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param float $temperature The temperature value.
+ * @return self
+ */
+ public function usingTemperature(float $temperature): self
+ {
+ $this->modelConfig->setTemperature($temperature);
+ return $this;
+ }
+ /**
+ * Sets the top-p value for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param float $topP The top-p value.
+ * @return self
+ */
+ public function usingTopP(float $topP): self
+ {
+ $this->modelConfig->setTopP($topP);
+ return $this;
+ }
+ /**
+ * Sets the top-k value for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param int $topK The top-k value.
+ * @return self
+ */
+ public function usingTopK(int $topK): self
+ {
+ $this->modelConfig->setTopK($topK);
+ return $this;
+ }
+ /**
+ * Sets stop sequences for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param string ...$stopSequences The stop sequences.
+ * @return self
+ */
+ public function usingStopSequences(string ...$stopSequences): self
+ {
+ $this->modelConfig->setCustomOption('stopSequences', $stopSequences);
+ return $this;
+ }
+ /**
+ * Sets the number of candidates to generate.
+ *
+ * @since 0.1.0
+ *
+ * @param int $candidateCount The number of candidates.
+ * @return self
+ */
+ public function usingCandidateCount(int $candidateCount): self
+ {
+ $this->modelConfig->setCandidateCount($candidateCount);
+ return $this;
+ }
+ /**
+ * Sets the function declarations available to the model.
+ *
+ * @since 0.1.0
+ *
+ * @param FunctionDeclaration ...$functionDeclarations The function declarations.
+ * @return self
+ */
+ public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclarations): self
+ {
+ $this->modelConfig->setFunctionDeclarations($functionDeclarations);
+ return $this;
+ }
+ /**
+ * Sets the presence penalty for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param float $presencePenalty The presence penalty value.
+ * @return self
+ */
+ public function usingPresencePenalty(float $presencePenalty): self
+ {
+ $this->modelConfig->setPresencePenalty($presencePenalty);
+ return $this;
+ }
+ /**
+ * Sets the frequency penalty for generation.
+ *
+ * @since 0.1.0
+ *
+ * @param float $frequencyPenalty The frequency penalty value.
+ * @return self
+ */
+ public function usingFrequencyPenalty(float $frequencyPenalty): self
+ {
+ $this->modelConfig->setFrequencyPenalty($frequencyPenalty);
+ return $this;
+ }
+ /**
+ * Sets the web search configuration.
+ *
+ * @since 0.1.0
+ *
+ * @param WebSearch $webSearch The web search configuration.
+ * @return self
+ */
+ 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.
+ *
+ * If $topLogprobs is null, enables log probabilities.
+ * If $topLogprobs has a value, enables log probabilities and sets the number of top log probabilities to return.
+ *
+ * @since 0.1.0
+ *
+ * @param int|null $topLogprobs The number of top log probabilities to return, or null to enable log probabilities.
+ * @return self
+ */
+ public function usingTopLogprobs(?int $topLogprobs = null): self
+ {
+ // Always enable log probabilities
+ $this->modelConfig->setLogprobs(\true);
+ // If a specific number is provided, set it
+ if ($topLogprobs !== null) {
+ $this->modelConfig->setTopLogprobs($topLogprobs);
+ }
+ return $this;
+ }
+ /**
+ * Sets the output MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @param string $mimeType The MIME type.
+ * @return self
+ */
+ public function asOutputMimeType(string $mimeType): self
+ {
+ $this->modelConfig->setOutputMimeType($mimeType);
+ return $this;
+ }
+ /**
+ * Sets the output schema.
+ *
+ * @since 0.1.0
+ *
+ * @param array $schema The output schema.
+ * @return self
+ */
+ public function asOutputSchema(array $schema): self
+ {
+ $this->modelConfig->setOutputSchema($schema);
+ return $this;
+ }
+ /**
+ * Sets the output modalities.
+ *
+ * @since 0.1.0
+ *
+ * @param ModalityEnum ...$modalities The output modalities.
+ * @return self
+ */
+ public function asOutputModalities(ModalityEnum ...$modalities): self
+ {
+ $this->modelConfig->setOutputModalities($modalities);
+ return $this;
+ }
+ /**
+ * Sets the output file type.
+ *
+ * @since 0.1.0
+ *
+ * @param FileTypeEnum $fileType The output file type.
+ * @return self
+ */
+ public function asOutputFileType(FileTypeEnum $fileType): self
+ {
+ $this->modelConfig->setOutputFileType($fileType);
+ return $this;
+ }
+ /**
+ * Configures the prompt for JSON response output.
+ *
+ * @since 0.1.0
+ *
+ * @param array|null $schema Optional JSON schema.
+ * @return self
+ */
+ public function asJsonResponse(?array $schema = null): self
+ {
+ $this->asOutputMimeType('application/json');
+ if ($schema !== null) {
+ $this->asOutputSchema($schema);
+ }
+ return $this;
+ }
+ /**
+ * Infers the capability from configured output modalities.
+ *
+ * @since 0.1.0
+ *
+ * @return CapabilityEnum The inferred capability.
+ * @throws RuntimeException If the output modality is not supported.
+ */
+ private function inferCapabilityFromOutputModalities(): CapabilityEnum
+ {
+ // Get the configured output modalities
+ $outputModalities = $this->modelConfig->getOutputModalities();
+ // Default to text if no output modality is specified
+ if ($outputModalities === null || empty($outputModalities)) {
+ return CapabilityEnum::textGeneration();
+ }
+ // Multi-modal output (multiple modalities) defaults to text generation. This is temporary
+ // as a multi-modal interface will be implemented in the future.
+ if (count($outputModalities) > 1) {
+ return CapabilityEnum::textGeneration();
+ }
+ // Infer capability from single output modality
+ $outputModality = $outputModalities[0];
+ if ($outputModality->isText()) {
+ return CapabilityEnum::textGeneration();
+ } elseif ($outputModality->isImage()) {
+ return CapabilityEnum::imageGeneration();
+ } elseif ($outputModality->isAudio()) {
+ return CapabilityEnum::speechGeneration();
+ } elseif ($outputModality->isVideo()) {
+ return CapabilityEnum::videoGeneration();
+ } else {
+ // For unsupported modalities, provide a clear error message
+ throw new RuntimeException(sprintf('Output modality "%s" is not yet supported.', $outputModality->value));
+ }
+ }
+ /**
+ * Infers the capability from a model's implemented interfaces.
+ *
+ * @since 0.1.0
+ *
+ * @param ModelInterface $model The model to infer capability from.
+ * @return CapabilityEnum|null The inferred capability, or null if none can be inferred.
+ */
+ private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?CapabilityEnum
+ {
+ // Check model interfaces in order of preference
+ if ($model instanceof TextGenerationModelInterface) {
+ return CapabilityEnum::textGeneration();
+ }
+ if ($model instanceof ImageGenerationModelInterface) {
+ return CapabilityEnum::imageGeneration();
+ }
+ if ($model instanceof TextToSpeechConversionModelInterface) {
+ return CapabilityEnum::textToSpeechConversion();
+ }
+ if ($model instanceof SpeechGenerationModelInterface) {
+ return CapabilityEnum::speechGeneration();
+ }
+ // No supported interface found
+ return null;
+ }
+ /**
+ * Checks if the current prompt is supported by the selected model.
+ *
+ * @since 0.1.0
+ * @since 0.3.0 Method visibility changed to public.
+ *
+ * @param CapabilityEnum|null $capability Optional capability to check support for.
+ * @return bool True if supported, false otherwise.
+ */
+ 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);
+ if ($inferredCapability !== null) {
+ $capability = $inferredCapability;
+ }
+ }
+ // If still no capability, infer from output modalities
+ if ($capability === null) {
+ $capability = $this->inferCapabilityFromOutputModalities();
+ }
+ }
+ // 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;
+ }
+ }
+ /**
+ * Checks if the prompt is supported for text generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if text generation is supported.
+ */
+ public function isSupportedForTextGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::textGeneration());
+ }
+ /**
+ * Checks if the prompt is supported for image generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if image generation is supported.
+ */
+ public function isSupportedForImageGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::imageGeneration());
+ }
+ /**
+ * Checks if the prompt is supported for text to speech conversion.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if text to speech conversion is supported.
+ */
+ public function isSupportedForTextToSpeechConversion(): bool
+ {
+ return $this->isSupported(CapabilityEnum::textToSpeechConversion());
+ }
+ /**
+ * Checks if the prompt is supported for video generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if video generation is supported.
+ */
+ public function isSupportedForVideoGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::videoGeneration());
+ }
+ /**
+ * Checks if the prompt is supported for speech generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if speech generation is supported.
+ */
+ public function isSupportedForSpeechGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::speechGeneration());
+ }
+ /**
+ * Checks if the prompt is supported for music generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if music generation is supported.
+ */
+ public function isSupportedForMusicGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::musicGeneration());
+ }
+ /**
+ * Checks if the prompt is supported for embedding generation.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if embedding generation is supported.
+ */
+ public function isSupportedForEmbeddingGeneration(): bool
+ {
+ return $this->isSupported(CapabilityEnum::embeddingGeneration());
+ }
+ /**
+ * Generates a result from the prompt.
+ *
+ * This is the primary execution method that generates a result (containing
+ * potentially multiple candidates) based on the specified capability or
+ * the configured output modality.
+ *
+ * @since 0.1.0
+ *
+ * @param CapabilityEnum|null $capability Optional capability to use for generation.
+ * If null, capability is inferred from output modality.
+ * @return GenerativeAiResult The generated result containing candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support the required capability.
+ */
+ public function generateResult(?CapabilityEnum $capability = null): GenerativeAiResult
+ {
+ $this->validateMessages();
+ // 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);
+ if ($inferredCapability !== null) {
+ $capability = $inferredCapability;
+ }
+ }
+ // If still no capability, infer from output modalities
+ if ($capability === null) {
+ $capability = $this->inferCapabilityFromOutputModalities();
+ }
+ }
+ $model = $this->getConfiguredModel($capability);
+ // Dispatch BeforeGenerateResultEvent
+ $this->dispatchEvent(new BeforeGenerateResultEvent($this->messages, $model, $capability));
+ // Route to the appropriate generation method based on capability
+ $result = $this->executeModelGeneration($model, $capability, $this->messages);
+ // Dispatch AfterGenerateResultEvent
+ $this->dispatchEvent(new AfterGenerateResultEvent($this->messages, $model, $capability, $result));
+ return $result;
+ }
+ /**
+ * Executes the model generation based on capability.
+ *
+ * @since 0.4.0
+ *
+ * @param ModelInterface $model The model to use for generation.
+ * @param CapabilityEnum $capability The capability to use.
+ * @param list $messages The messages to send.
+ * @return GenerativeAiResult The generated result.
+ * @throws RuntimeException If the model doesn't support the required capability.
+ */
+ private function executeModelGeneration(ModelInterface $model, CapabilityEnum $capability, array $messages): GenerativeAiResult
+ {
+ if ($capability->isTextGeneration()) {
+ if (!$model instanceof TextGenerationModelInterface) {
+ throw new RuntimeException(sprintf('Model "%s" does not support text generation.', $model->metadata()->getId()));
+ }
+ return $model->generateTextResult($messages);
+ }
+ if ($capability->isImageGeneration()) {
+ if (!$model instanceof ImageGenerationModelInterface) {
+ throw new RuntimeException(sprintf('Model "%s" does not support image generation.', $model->metadata()->getId()));
+ }
+ return $model->generateImageResult($messages);
+ }
+ if ($capability->isTextToSpeechConversion()) {
+ if (!$model instanceof TextToSpeechConversionModelInterface) {
+ throw new RuntimeException(sprintf('Model "%s" does not support text-to-speech conversion.', $model->metadata()->getId()));
+ }
+ return $model->convertTextToSpeechResult($messages);
+ }
+ if ($capability->isSpeechGeneration()) {
+ if (!$model instanceof SpeechGenerationModelInterface) {
+ throw new RuntimeException(sprintf('Model "%s" does not support speech generation.', $model->metadata()->getId()));
+ }
+ return $model->generateSpeechResult($messages);
+ }
+ // Video generation is not yet implemented
+ if ($capability->isVideoGeneration()) {
+ throw new RuntimeException('Output modality "video" is not yet supported.');
+ }
+ // TODO: Add support for other capabilities when interfaces are available
+ throw new RuntimeException(sprintf('Capability "%s" is not yet supported for generation.', $capability->value));
+ }
+ /**
+ * Generates a text result from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResult The generated result containing text candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support text generation.
+ */
+ public function generateTextResult(): GenerativeAiResult
+ {
+ // Include text in output modalities
+ $this->includeOutputModalities(ModalityEnum::text());
+ // Generate and return the result with text generation capability
+ return $this->generateResult(CapabilityEnum::textGeneration());
+ }
+ /**
+ * Generates an image result from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResult The generated result containing image candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support image generation.
+ */
+ public function generateImageResult(): GenerativeAiResult
+ {
+ // Include image in output modalities
+ $this->includeOutputModalities(ModalityEnum::image());
+ // Generate and return the result with image generation capability
+ return $this->generateResult(CapabilityEnum::imageGeneration());
+ }
+ /**
+ * Generates a speech result from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResult The generated result containing speech audio candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support speech generation.
+ */
+ public function generateSpeechResult(): GenerativeAiResult
+ {
+ // Include audio in output modalities
+ $this->includeOutputModalities(ModalityEnum::audio());
+ // Generate and return the result with speech generation capability
+ return $this->generateResult(CapabilityEnum::speechGeneration());
+ }
+ /**
+ * Converts text to speech and returns the result.
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResult The generated result containing speech audio candidates.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If the model doesn't support text-to-speech conversion.
+ */
+ public function convertTextToSpeechResult(): GenerativeAiResult
+ {
+ // Include audio in output modalities
+ $this->includeOutputModalities(ModalityEnum::audio());
+ // Generate and return the result with text-to-speech conversion capability
+ return $this->generateResult(CapabilityEnum::textToSpeechConversion());
+ }
+ /**
+ * Generates text from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return string The generated text.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ */
+ public function generateText(): string
+ {
+ return $this->generateTextResult()->toText();
+ }
+ /**
+ * Generates multiple text candidates from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @param int|null $candidateCount The number of candidates to generate.
+ * @return list The generated texts.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ */
+ public function generateTexts(?int $candidateCount = null): array
+ {
+ if ($candidateCount !== null) {
+ $this->usingCandidateCount($candidateCount);
+ }
+ // Generate text result
+ return $this->generateTextResult()->toTexts();
+ }
+ /**
+ * Generates an image from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return File The generated image file.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no image is generated.
+ */
+ public function generateImage(): File
+ {
+ return $this->generateImageResult()->toFile();
+ }
+ /**
+ * Generates multiple images from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @param int|null $candidateCount The number of images to generate.
+ * @return list The generated image files.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no images are generated.
+ */
+ public function generateImages(?int $candidateCount = null): array
+ {
+ if ($candidateCount !== null) {
+ $this->usingCandidateCount($candidateCount);
+ }
+ return $this->generateImageResult()->toFiles();
+ }
+ /**
+ * Converts text to speech.
+ *
+ * @since 0.1.0
+ *
+ * @return File The generated speech audio file.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no audio is generated.
+ */
+ public function convertTextToSpeech(): File
+ {
+ return $this->convertTextToSpeechResult()->toFile();
+ }
+ /**
+ * Converts text to multiple speech outputs.
+ *
+ * @since 0.1.0
+ *
+ * @param int|null $candidateCount The number of speech outputs to generate.
+ * @return list The generated speech audio files.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no audio is generated.
+ */
+ public function convertTextToSpeeches(?int $candidateCount = null): array
+ {
+ if ($candidateCount !== null) {
+ $this->usingCandidateCount($candidateCount);
+ }
+ return $this->convertTextToSpeechResult()->toFiles();
+ }
+ /**
+ * Generates speech from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @return File The generated speech audio file.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no audio is generated.
+ */
+ public function generateSpeech(): File
+ {
+ return $this->generateSpeechResult()->toFile();
+ }
+ /**
+ * Generates multiple speech outputs from the prompt.
+ *
+ * @since 0.1.0
+ *
+ * @param int|null $candidateCount The number of speech outputs to generate.
+ * @return list The generated speech audio files.
+ * @throws InvalidArgumentException If the prompt or model validation fails.
+ * @throws RuntimeException If no audio is generated.
+ */
+ public function generateSpeeches(?int $candidateCount = null): array
+ {
+ if ($candidateCount !== null) {
+ $this->usingCandidateCount($candidateCount);
+ }
+ return $this->generateSpeechResult()->toFiles();
+ }
+ /**
+ * Appends a MessagePart to the messages array.
+ *
+ * If the last message has a user role, the part is added to it.
+ * Otherwise, a new UserMessage is created with the part.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart $part The part to append.
+ * @return void
+ */
+ protected function appendPartToMessages(MessagePart $part): void
+ {
+ $lastMessage = end($this->messages);
+ if ($lastMessage instanceof Message && $lastMessage->getRole()->isUser()) {
+ // Replace the last message with a new one containing the appended part
+ array_pop($this->messages);
+ $this->messages[] = $lastMessage->withPart($part);
+ return;
+ }
+ // Create new UserMessage with the part
+ $this->messages[] = new UserMessage([$part]);
+ }
+ /**
+ * Gets the model to use for generation.
+ *
+ * If a model has been explicitly set, validates it meets requirements and returns it.
+ * Otherwise, finds a suitable model based on the prompt requirements.
+ *
+ * @since 0.1.0
+ *
+ * @param CapabilityEnum $capability The capability the model will be using.
+ * @return ModelInterface The model to use.
+ * @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements.
+ */
+ 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;
+ }
+ /**
+ * Parses various input types into a Message with the given role.
+ *
+ * @since 0.1.0
+ *
+ * @param mixed $input The input to parse.
+ * @param MessageRoleEnum $defaultRole The role for the message if not specified by input.
+ * @return Message The parsed message.
+ * @throws InvalidArgumentException If the input type is not supported or results in empty message.
+ */
+ private function parseMessage($input, MessageRoleEnum $defaultRole): Message
+ {
+ // Handle Message input directly
+ if ($input instanceof Message) {
+ return $input;
+ }
+ // Handle single MessagePart
+ if ($input instanceof MessagePart) {
+ return new Message($defaultRole, [$input]);
+ }
+ // Handle string input
+ if (is_string($input)) {
+ if (trim($input) === '') {
+ throw new InvalidArgumentException('Cannot create a message from an empty string.');
+ }
+ return new Message($defaultRole, [new MessagePart($input)]);
+ }
+ // Handle array input
+ if (!is_array($input)) {
+ throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, ' . 'a list of string|MessagePart|MessagePartArrayShape, or a Message instance.');
+ }
+ // Handle MessageArrayShape input
+ if (Message::isArrayShape($input)) {
+ return Message::fromArray($input);
+ }
+ // Check if it's a MessagePartArrayShape
+ if (MessagePart::isArrayShape($input)) {
+ return new Message($defaultRole, [MessagePart::fromArray($input)]);
+ }
+ // It should be a list of string|MessagePart|MessagePartArrayShape
+ if (!array_is_list($input)) {
+ throw new InvalidArgumentException('Array input must be a list array.');
+ }
+ // Empty array check
+ if (empty($input)) {
+ throw new InvalidArgumentException('Cannot create a message from an empty array.');
+ }
+ $parts = [];
+ foreach ($input as $item) {
+ if (is_string($item)) {
+ $parts[] = new MessagePart($item);
+ } elseif ($item instanceof MessagePart) {
+ $parts[] = $item;
+ } elseif (is_array($item) && MessagePart::isArrayShape($item)) {
+ $parts[] = MessagePart::fromArray($item);
+ } else {
+ throw new InvalidArgumentException('Array items must be strings, MessagePart instances, or MessagePartArrayShape.');
+ }
+ }
+ return new Message($defaultRole, $parts);
+ }
+ /**
+ * Validates the messages array for prompt generation.
+ *
+ * Ensures that:
+ * - The first message is a user message
+ * - The last message is a user message
+ * - The last message has parts
+ *
+ * @since 0.1.0
+ *
+ * @return void
+ * @throws InvalidArgumentException If validation fails.
+ */
+ private function validateMessages(): void
+ {
+ if (empty($this->messages)) {
+ throw new InvalidArgumentException('Cannot generate from an empty prompt. Add content using withText() or similar methods.');
+ }
+ $firstMessage = reset($this->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);
+ if (!$lastMessage->getRole()->isUser()) {
+ throw new InvalidArgumentException('The last message must be from a user role, not from ' . $lastMessage->getRole()->value);
+ }
+ if (empty($lastMessage->getParts())) {
+ throw new InvalidArgumentException('The last message must have content parts. Add content using withText() or similar methods.');
+ }
+ }
+ /**
+ * Checks if the value is a list of Message objects.
+ *
+ * @since 0.1.0
+ *
+ * @param mixed $value The value to check.
+ * @return bool True if the value is a list of Message objects.
+ *
+ * @phpstan-assert-if-true list $value
+ */
+ private function isMessagesList($value): bool
+ {
+ if (!is_array($value) || empty($value) || !array_is_list($value)) {
+ return \false;
+ }
+ // Check if all items are Messages
+ foreach ($value as $item) {
+ if (!$item instanceof Message) {
+ return \false;
+ }
+ }
+ return \true;
+ }
+ /**
+ * Includes output modalities if not already present.
+ *
+ * Adds the given modalities to the output modalities list if they're not
+ * already included. If output modalities is null, initializes it with
+ * the given modalities.
+ *
+ * @since 0.1.0
+ *
+ * @param ModalityEnum ...$modalities The modalities to include.
+ * @return void
+ */
+ private function includeOutputModalities(ModalityEnum ...$modalities): void
+ {
+ $existing = $this->modelConfig->getOutputModalities();
+ // Initialize if null
+ if ($existing === null) {
+ $this->modelConfig->setOutputModalities($modalities);
+ return;
+ }
+ // Build a set of existing modality values for O(1) lookup
+ $existingValues = [];
+ foreach ($existing as $existingModality) {
+ $existingValues[$existingModality->value] = \true;
+ }
+ // Add new modalities that don't exist
+ $toAdd = [];
+ foreach ($modalities as $modality) {
+ if (!isset($existingValues[$modality->value])) {
+ $toAdd[] = $modality;
+ }
+ }
+ // Update if we have new modalities to add
+ if (!empty($toAdd)) {
+ $this->modelConfig->setOutputModalities(array_merge($existing, $toAdd));
+ }
+ }
+ /**
+ * Dispatches an event if an event dispatcher is registered.
+ *
+ * @since 0.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/Common/AbstractDataTransferObject.php b/src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php
new file mode 100644
index 0000000000000..cf396c9219415
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php
@@ -0,0 +1,126 @@
+
+ * @implements WithArrayTransformationInterface
+ */
+abstract class AbstractDataTransferObject implements WithArrayTransformationInterface, WithJsonSchemaInterface, JsonSerializable
+{
+ /**
+ * Validates that required keys exist in the array data.
+ *
+ * @since 0.1.0
+ *
+ * @param array $data The array data to validate.
+ * @param string[] $requiredKeys The keys that must be present.
+ * @throws InvalidArgumentException If any required key is missing.
+ */
+ protected static function validateFromArrayData(array $data, array $requiredKeys): void
+ {
+ $missingKeys = [];
+ foreach ($requiredKeys as $key) {
+ if (!array_key_exists($key, $data)) {
+ $missingKeys[] = $key;
+ }
+ }
+ if (!empty($missingKeys)) {
+ throw new InvalidArgumentException(sprintf('%s::fromArray() missing required keys: %s', static::class, implode(', ', $missingKeys)));
+ }
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function isArrayShape(array $array): bool
+ {
+ try {
+ /** @var TArrayShape $array */
+ static::fromArray($array);
+ return \true;
+ } catch (InvalidArgumentException $e) {
+ return \false;
+ }
+ }
+ /**
+ * Converts the object to a JSON-serializable format.
+ *
+ * This method uses the toArray() method and then processes the result
+ * based on the JSON schema to ensure proper object representation for
+ * empty arrays.
+ *
+ * @since 0.1.0
+ *
+ * @return mixed The JSON-serializable representation.
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize()
+ {
+ $data = $this->toArray();
+ $schema = static::getJsonSchema();
+ return $this->convertEmptyArraysToObjects($data, $schema);
+ }
+ /**
+ * Recursively converts empty arrays to stdClass objects where the schema expects objects.
+ *
+ * @since 0.1.0
+ *
+ * @param mixed $data The data to process.
+ * @param array $schema The JSON schema for the data.
+ * @return mixed The processed data.
+ */
+ private function convertEmptyArraysToObjects($data, array $schema)
+ {
+ // If data is an empty array and schema expects object, convert to stdClass
+ if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') {
+ return new stdClass();
+ }
+ // If data is an array with content, recursively process nested structures
+ if (is_array($data)) {
+ // Handle object properties
+ if (isset($schema['properties']) && is_array($schema['properties'])) {
+ foreach ($data as $key => $value) {
+ if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) {
+ $data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]);
+ }
+ }
+ }
+ // Handle array items
+ if (isset($schema['items']) && is_array($schema['items'])) {
+ foreach ($data as $index => $item) {
+ $data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']);
+ }
+ }
+ // Handle oneOf schemas - just use the first one
+ if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
+ foreach ($schema['oneOf'] as $possibleSchema) {
+ if (is_array($possibleSchema)) {
+ return $this->convertEmptyArraysToObjects($data, $possibleSchema);
+ }
+ }
+ }
+ }
+ return $data;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php b/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php
new file mode 100644
index 0000000000000..7589c70771901
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php
@@ -0,0 +1,349 @@
+name; // 'FIRST_NAME'
+ * $enum->value; // 'first'
+ * $enum->equals('first'); // Returns true
+ * $enum->is(PersonEnum::firstName()); // Returns true
+ * PersonEnum::cases(); // Returns array of all enum instances
+ *
+ * @property-read string $value The value of the enum instance.
+ * @property-read string $name The name of the enum constant.
+ *
+ * @since 0.1.0
+ */
+abstract class AbstractEnum implements JsonSerializable
+{
+ /**
+ * @var string The value of the enum instance.
+ */
+ private string $value;
+ /**
+ * @var string The name of the enum constant.
+ */
+ private string $name;
+ /**
+ * @var array> Cache for reflection data.
+ */
+ private static array $cache = [];
+ /**
+ * @var array> Cache for enum instances.
+ */
+ private static array $instances = [];
+ /**
+ * Constructor is private to ensure instances are created through static methods.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The enum value.
+ * @param string $name The constant name.
+ */
+ final private function __construct(string $value, string $name)
+ {
+ $this->value = $value;
+ $this->name = $name;
+ }
+ /**
+ * Provides read-only access to properties.
+ *
+ * @since 0.1.0
+ *
+ * @param string $property The property name.
+ * @return mixed The property value.
+ * @throws BadMethodCallException If property doesn't exist.
+ */
+ final public function __get(string $property)
+ {
+ if ($property === 'value' || $property === 'name') {
+ return $this->{$property};
+ }
+ throw new BadMethodCallException(sprintf('Property %s::%s does not exist', static::class, $property));
+ }
+ /**
+ * Prevents property modification.
+ *
+ * @since 0.1.0
+ *
+ * @param string $property The property name.
+ * @param mixed $value The value to set.
+ * @throws BadMethodCallException Always, as enum properties are read-only.
+ */
+ final public function __set(string $property, $value): void
+ {
+ throw new BadMethodCallException(sprintf('Cannot modify property %s::%s - enum properties are read-only', static::class, $property));
+ }
+ /**
+ * Creates an enum instance from a value, throws exception if invalid.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The enum value.
+ * @return static The enum instance.
+ * @throws InvalidArgumentException If the value is not valid.
+ */
+ final public static function from(string $value): self
+ {
+ $instance = self::tryFrom($value);
+ if ($instance === null) {
+ throw new InvalidArgumentException(sprintf('%s is not a valid backing value for enum %s', $value, static::class));
+ }
+ return $instance;
+ }
+ /**
+ * Tries to create an enum instance from a value, returns null if invalid.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The enum value.
+ * @return static|null The enum instance or null.
+ */
+ final public static function tryFrom(string $value): ?self
+ {
+ $constants = static::getConstants();
+ foreach ($constants as $name => $constantValue) {
+ if ($constantValue === $value) {
+ return self::getInstance($constantValue, $name);
+ }
+ }
+ return null;
+ }
+ /**
+ * Gets all enum cases.
+ *
+ * @since 0.1.0
+ *
+ * @return static[] Array of all enum instances.
+ */
+ final public static function cases(): array
+ {
+ $cases = [];
+ $constants = static::getConstants();
+ foreach ($constants as $name => $value) {
+ $cases[] = self::getInstance($value, $name);
+ }
+ return $cases;
+ }
+ /**
+ * Checks if this enum has the same value as the given value.
+ *
+ * @since 0.1.0
+ *
+ * @param string|self $other The value or enum to compare.
+ * @return bool True if values are equal.
+ */
+ final public function equals($other): bool
+ {
+ if ($other instanceof self) {
+ return $this->is($other);
+ }
+ return $this->value === $other;
+ }
+ /**
+ * Checks if this enum is the same instance type and value as another enum.
+ *
+ * @since 0.1.0
+ *
+ * @param self $other The other enum to compare.
+ * @return bool True if enums are identical.
+ */
+ final public function is(self $other): bool
+ {
+ return $this === $other;
+ // Since we're using singletons, we can use identity comparison
+ }
+ /**
+ * Gets all valid values for this enum.
+ *
+ * @since 0.1.0
+ *
+ * @return string[] List of all enum values.
+ */
+ final public static function getValues(): array
+ {
+ return array_values(static::getConstants());
+ }
+ /**
+ * Checks if a value is valid for this enum.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The value to check.
+ * @return bool True if value is valid.
+ */
+ final public static function isValidValue(string $value): bool
+ {
+ return in_array($value, self::getValues(), \true);
+ }
+ /**
+ * Gets or creates a singleton instance for the given value and name.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The enum value.
+ * @param string $name The constant name.
+ * @return static The enum instance.
+ */
+ private static function getInstance(string $value, string $name): self
+ {
+ $className = static::class;
+ if (!isset(self::$instances[$className])) {
+ self::$instances[$className] = [];
+ }
+ if (!isset(self::$instances[$className][$name])) {
+ $instance = new $className($value, $name);
+ self::$instances[$className][$name] = $instance;
+ }
+ /** @var static */
+ return self::$instances[$className][$name];
+ }
+ /**
+ * Gets all constants for this enum class.
+ *
+ * @since 0.1.0
+ *
+ * @return array Map of constant names to values.
+ * @throws RuntimeException If invalid constant found.
+ */
+ final protected static function getConstants(): array
+ {
+ $className = static::class;
+ if (!isset(self::$cache[$className])) {
+ self::$cache[$className] = static::determineClassEnumerations($className);
+ }
+ return self::$cache[$className];
+ }
+ /**
+ * Determines the class enumerations by reflecting on class constants.
+ *
+ * This method can be overridden by subclasses to customize how
+ * enumerations are determined (e.g., to add dynamic constants).
+ *
+ * @since 0.1.0
+ *
+ * @param class-string $className The fully qualified class name.
+ * @return array Map of constant names to values.
+ * @throws RuntimeException If invalid constant found.
+ */
+ protected static function determineClassEnumerations(string $className): array
+ {
+ $reflection = new ReflectionClass($className);
+ $constants = $reflection->getConstants();
+ // Validate all constants
+ $enumConstants = [];
+ foreach ($constants as $name => $value) {
+ // Check if constant name follows uppercase snake_case pattern
+ if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
+ throw new RuntimeException(sprintf('Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', $name, $className));
+ }
+ // Check if value is valid type
+ if (!is_string($value)) {
+ throw new RuntimeException(sprintf('Invalid enum value type for constant %s::%s. ' . 'Only string values are allowed, %s given.', $className, $name, gettype($value)));
+ }
+ $enumConstants[$name] = $value;
+ }
+ return $enumConstants;
+ }
+ /**
+ * Handles dynamic method calls for enum checking.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The method name.
+ * @param array $arguments The method arguments.
+ * @return bool True if the enum value matches.
+ * @throws BadMethodCallException If the method doesn't exist.
+ */
+ final public function __call(string $name, array $arguments): bool
+ {
+ // Handle is* methods
+ if (str_starts_with($name, 'is')) {
+ $constantName = self::camelCaseToConstant(substr($name, 2));
+ $constants = static::getConstants();
+ if (isset($constants[$constantName])) {
+ return $this->value === $constants[$constantName];
+ }
+ }
+ throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
+ }
+ /**
+ * Handles static method calls for enum creation.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The method name.
+ * @param array $arguments The method arguments.
+ * @return static The enum instance.
+ * @throws BadMethodCallException If the method doesn't exist.
+ */
+ final public static function __callStatic(string $name, array $arguments): self
+ {
+ $constantName = self::camelCaseToConstant($name);
+ $constants = static::getConstants();
+ if (isset($constants[$constantName])) {
+ return self::getInstance($constants[$constantName], $constantName);
+ }
+ throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
+ }
+ /**
+ * Converts camelCase to CONSTANT_CASE.
+ *
+ * @since 0.1.0
+ *
+ * @param string $camelCase The camelCase string.
+ * @return string The CONSTANT_CASE version.
+ */
+ private static function camelCaseToConstant(string $camelCase): string
+ {
+ $snakeCase = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase);
+ if ($snakeCase === null) {
+ return strtoupper($camelCase);
+ }
+ return strtoupper($snakeCase);
+ }
+ /**
+ * Returns string representation of the enum.
+ *
+ * @since 0.1.0
+ *
+ * @return string The enum value.
+ */
+ final public function __toString(): string
+ {
+ return $this->value;
+ }
+ /**
+ * Converts the enum to a JSON-serializable format.
+ *
+ * @since 0.1.0
+ *
+ * @return string The enum value.
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize()
+ {
+ return $this->value;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php b/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php
new file mode 100644
index 0000000000000..23d6256b20fa1
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php
@@ -0,0 +1,17 @@
+
+ */
+interface WithArrayTransformationInterface
+{
+ /**
+ * Converts the object to an array representation.
+ *
+ * @since 0.1.0
+ *
+ * @return TArrayShape The array representation.
+ */
+ public function toArray(): array;
+ /**
+ * Creates an instance from array data.
+ *
+ * @since 0.1.0
+ *
+ * @param TArrayShape $array The array data.
+ * @return self The created instance.
+ */
+ public static function fromArray(array $array): self;
+ /**
+ * Checks if the array is a valid shape for this object.
+ *
+ * @since 0.1.0
+ *
+ * @param array $array The array to check.
+ * @return bool True if the array is a valid shape.
+ * @phpstan-assert-if-true TArrayShape $array
+ */
+ public static function isArrayShape(array $array): bool;
+}
diff --git a/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php b/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php
new file mode 100644
index 0000000000000..a90375349476a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php
@@ -0,0 +1,24 @@
+ The JSON schema as an associative array.
+ */
+ public static function getJsonSchema(): array;
+}
diff --git a/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php b/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php
new file mode 100644
index 0000000000000..7055cc926ae69
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php
@@ -0,0 +1,17 @@
+
+ */
+ private array $localCache = [];
+ /**
+ * Gets the cache key suffixes managed by this object.
+ *
+ * @since 0.4.0
+ *
+ * @return list The cache key suffixes.
+ */
+ abstract protected function getCachedKeys(): array;
+ /**
+ * Gets the base cache key for this object.
+ *
+ * The base cache key is used as a prefix for all cache keys managed by this object.
+ * It should be unique to the implementing class to avoid cache key collisions.
+ *
+ * @since 0.4.0
+ *
+ * @return string The base cache key.
+ */
+ abstract protected function getBaseCacheKey(): string;
+ /**
+ * Checks if a value exists in the cache.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix (will be appended to the base key).
+ * @return bool True if the value exists in cache, false otherwise.
+ */
+ protected function hasCache(string $key): bool
+ {
+ $fullKey = $this->buildCacheKey($key);
+ $cache = AiClient::getCache();
+ if ($cache !== null) {
+ return $cache->has($fullKey);
+ }
+ return array_key_exists($fullKey, $this->localCache);
+ }
+ /**
+ * Gets a value from the cache, or computes and caches it if not present.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix (will be appended to the base key).
+ * @param callable $callback The callback to compute the value if not cached.
+ * @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default.
+ * Ignored for local cache.
+ * @return mixed The cached or computed value.
+ */
+ protected function cached(string $key, callable $callback, $ttl = null)
+ {
+ if ($this->hasCache($key)) {
+ return $this->getCache($key);
+ }
+ $value = $callback();
+ $this->setCache($key, $value, $ttl);
+ return $value;
+ }
+ /**
+ * Gets a value from the cache.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix (will be appended to the base key).
+ * @param mixed $default The default value to return if the key does not exist.
+ * @return mixed The cached value or the default value if not found.
+ */
+ protected function getCache(string $key, $default = null)
+ {
+ $fullKey = $this->buildCacheKey($key);
+ $cache = AiClient::getCache();
+ if ($cache !== null) {
+ return $cache->get($fullKey, $default);
+ }
+ return $this->localCache[$fullKey] ?? $default;
+ }
+ /**
+ * Sets a value in the cache.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix (will be appended to the base key).
+ * @param mixed $value The value to cache.
+ * @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default. Ignored for local cache.
+ * @return bool True on success, false on failure.
+ */
+ protected function setCache(string $key, $value, $ttl = null): bool
+ {
+ $fullKey = $this->buildCacheKey($key);
+ $cache = AiClient::getCache();
+ if ($cache !== null) {
+ return $cache->set($fullKey, $value, $ttl);
+ }
+ $this->localCache[$fullKey] = $value;
+ return \true;
+ }
+ /**
+ * Invalidates all caches managed by this object.
+ *
+ * @since 0.4.0
+ *
+ * @return void
+ */
+ public function invalidateCaches(): void
+ {
+ foreach ($this->getCachedKeys() as $key) {
+ $this->clearCache($key);
+ }
+ }
+ /**
+ * Clears a value from the cache.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix (will be appended to the base key).
+ * @return bool True on success, false on failure.
+ */
+ protected function clearCache(string $key): bool
+ {
+ $fullKey = $this->buildCacheKey($key);
+ $cache = AiClient::getCache();
+ if ($cache !== null) {
+ return $cache->delete($fullKey);
+ }
+ unset($this->localCache[$fullKey]);
+ return \true;
+ }
+ /**
+ * Builds the full cache key by combining the base key with the suffix.
+ *
+ * @since 0.4.0
+ *
+ * @param string $key The cache key suffix.
+ * @return string The full cache key.
+ */
+ private function buildCacheKey(string $key): string
+ {
+ return $this->getBaseCacheKey() . '_' . $key;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php b/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php
new file mode 100644
index 0000000000000..d20c6fc07ba1b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php
@@ -0,0 +1,115 @@
+ The messages that were sent to the model.
+ */
+ private array $messages;
+ /**
+ * @var ModelInterface The model that processed the prompt.
+ */
+ private ModelInterface $model;
+ /**
+ * @var CapabilityEnum|null The capability that was used for generation.
+ */
+ private ?CapabilityEnum $capability;
+ /**
+ * @var GenerativeAiResult The result from the model.
+ */
+ private GenerativeAiResult $result;
+ /**
+ * Constructor.
+ *
+ * @since 0.4.0
+ *
+ * @param list $messages The messages that were sent to the model.
+ * @param ModelInterface $model The model that processed the prompt.
+ * @param CapabilityEnum|null $capability The capability that was used for generation.
+ * @param GenerativeAiResult $result The result from the model.
+ */
+ public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability, GenerativeAiResult $result)
+ {
+ $this->messages = $messages;
+ $this->model = $model;
+ $this->capability = $capability;
+ $this->result = $result;
+ }
+ /**
+ * Gets the messages that were sent to the model.
+ *
+ * @since 0.4.0
+ *
+ * @return list The messages.
+ */
+ public function getMessages(): array
+ {
+ return $this->messages;
+ }
+ /**
+ * Gets the model that processed the prompt.
+ *
+ * @since 0.4.0
+ *
+ * @return ModelInterface The model.
+ */
+ public function getModel(): ModelInterface
+ {
+ return $this->model;
+ }
+ /**
+ * Gets the capability that was used for generation.
+ *
+ * @since 0.4.0
+ *
+ * @return CapabilityEnum|null The capability, or null if not specified.
+ */
+ public function getCapability(): ?CapabilityEnum
+ {
+ return $this->capability;
+ }
+ /**
+ * Gets the result from the model.
+ *
+ * @since 0.4.0
+ *
+ * @return GenerativeAiResult The result.
+ */
+ public function getResult(): GenerativeAiResult
+ {
+ return $this->result;
+ }
+ /**
+ * Performs a deep clone of the event.
+ *
+ * This method ensures that message and result objects are cloned to prevent
+ * modifications to the cloned event from affecting the original.
+ * The model object is not cloned as it is a service object.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $clonedMessages = [];
+ foreach ($this->messages as $message) {
+ $clonedMessages[] = clone $message;
+ }
+ $this->messages = $clonedMessages;
+ $this->result = clone $this->result;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php
new file mode 100644
index 0000000000000..553d9d8cad849
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php
@@ -0,0 +1,97 @@
+ The messages to be sent to the model.
+ */
+ private array $messages;
+ /**
+ * @var ModelInterface The model that will process the prompt.
+ */
+ private ModelInterface $model;
+ /**
+ * @var CapabilityEnum|null The capability being used for generation.
+ */
+ private ?CapabilityEnum $capability;
+ /**
+ * Constructor.
+ *
+ * @since 0.4.0
+ *
+ * @param list $messages The messages to be sent to the model.
+ * @param ModelInterface $model The model that will process the prompt.
+ * @param CapabilityEnum|null $capability The capability being used for generation.
+ */
+ public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability)
+ {
+ $this->messages = $messages;
+ $this->model = $model;
+ $this->capability = $capability;
+ }
+ /**
+ * Gets the messages to be sent to the model.
+ *
+ * @since 0.4.0
+ *
+ * @return list The messages.
+ */
+ public function getMessages(): array
+ {
+ return $this->messages;
+ }
+ /**
+ * Gets the model that will process the prompt.
+ *
+ * @since 0.4.0
+ *
+ * @return ModelInterface The model.
+ */
+ public function getModel(): ModelInterface
+ {
+ return $this->model;
+ }
+ /**
+ * Gets the capability being used for generation.
+ *
+ * @since 0.4.0
+ *
+ * @return CapabilityEnum|null The capability, or null if not specified.
+ */
+ public function getCapability(): ?CapabilityEnum
+ {
+ return $this->capability;
+ }
+ /**
+ * Performs a deep clone of the event.
+ *
+ * This method ensures that message objects are cloned to prevent
+ * modifications to the cloned event from affecting the original.
+ * The model object is not cloned as it is a service object.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $clonedMessages = [];
+ foreach ($this->messages as $message) {
+ $clonedMessages[] = clone $message;
+ }
+ $this->messages = $clonedMessages;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Files/DTO/File.php b/src/wp-includes/php-ai-client/src/Files/DTO/File.php
new file mode 100644
index 0000000000000..c032041dae4ba
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Files/DTO/File.php
@@ -0,0 +1,400 @@
+
+ */
+class File extends AbstractDataTransferObject
+{
+ public const KEY_FILE_TYPE = 'fileType';
+ public const KEY_MIME_TYPE = 'mimeType';
+ public const KEY_URL = 'url';
+ public const KEY_BASE64_DATA = 'base64Data';
+ /**
+ * @var MimeType The MIME type of the file.
+ */
+ private MimeType $mimeType;
+ /**
+ * @var FileTypeEnum The type of file storage.
+ */
+ private FileTypeEnum $fileType;
+ /**
+ * @var string|null The URL for remote files.
+ */
+ private ?string $url = null;
+ /**
+ * @var string|null The base64 data for inline files.
+ */
+ private ?string $base64Data = null;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $file The file string (URL, base64 data, or local path).
+ * @param string|null $mimeType The MIME type of the file (optional).
+ * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
+ */
+ public function __construct(string $file, ?string $mimeType = null)
+ {
+ // Detect and process the file type (will set MIME type if possible)
+ $this->detectAndProcessFile($file, $mimeType);
+ }
+ /**
+ * Detects the file type and processes it accordingly.
+ *
+ * @since 0.1.0
+ *
+ * @param string $file The file string to process.
+ * @param string|null $providedMimeType The explicitly provided MIME type.
+ * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
+ */
+ private function detectAndProcessFile(string $file, ?string $providedMimeType): void
+ {
+ // Check if it's a URL
+ if ($this->isUrl($file)) {
+ $this->fileType = FileTypeEnum::remote();
+ $this->url = $file;
+ $this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
+ return;
+ }
+ // Data URI pattern.
+ $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/';
+ // Check if it's a data URI.
+ if (preg_match($dataUriPattern, $file, $matches)) {
+ $this->fileType = FileTypeEnum::inline();
+ $this->base64Data = $matches[2];
+ // Extract just the base64 data
+ $extractedMimeType = empty($matches[1]) ? null : $matches[1];
+ $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null);
+ return;
+ }
+ // Check if it's a local file path (before base64 check)
+ if (file_exists($file) && is_file($file)) {
+ $this->fileType = FileTypeEnum::inline();
+ $this->base64Data = $this->convertFileToBase64($file);
+ $this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
+ return;
+ }
+ // Check if it's plain base64
+ if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) {
+ if ($providedMimeType === null) {
+ throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.');
+ }
+ $this->fileType = FileTypeEnum::inline();
+ $this->base64Data = $file;
+ $this->mimeType = new MimeType($providedMimeType);
+ return;
+ }
+ throw new InvalidArgumentException('Invalid file provided. Expected URL, base64 data, or valid local file path.');
+ }
+ /**
+ * Checks if a string is a valid URL.
+ *
+ * @since 0.1.0
+ *
+ * @param string $string The string to check.
+ * @return bool True if the string is a URL.
+ */
+ private function isUrl(string $string): bool
+ {
+ return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string);
+ }
+ /**
+ * Converts a local file to base64.
+ *
+ * @since 0.1.0
+ *
+ * @param string $filePath The path to the local file.
+ * @return string The base64-encoded file data.
+ * @throws RuntimeException If the file cannot be read.
+ */
+ private function convertFileToBase64(string $filePath): string
+ {
+ $fileContent = @file_get_contents($filePath);
+ if ($fileContent === \false) {
+ throw new RuntimeException(sprintf('Unable to read file: %s', $filePath));
+ }
+ return base64_encode($fileContent);
+ }
+ /**
+ * Gets the file type.
+ *
+ * @since 0.1.0
+ *
+ * @return FileTypeEnum The file type.
+ */
+ public function getFileType(): FileTypeEnum
+ {
+ return $this->fileType;
+ }
+ /**
+ * Checks if the file is an inline file.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is inline (base64/data URI).
+ */
+ public function isInline(): bool
+ {
+ return $this->fileType->isInline();
+ }
+ /**
+ * Checks if the file is a remote file.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is remote (URL).
+ */
+ public function isRemote(): bool
+ {
+ return $this->fileType->isRemote();
+ }
+ /**
+ * Gets the URL for remote files.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The URL, or null if not a remote file.
+ */
+ public function getUrl(): ?string
+ {
+ return $this->url;
+ }
+ /**
+ * Gets the base64-encoded data for inline files.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file.
+ */
+ public function getBase64Data(): ?string
+ {
+ return $this->base64Data;
+ }
+ /**
+ * Gets the data as a data URI for inline files.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file.
+ */
+ public function getDataUri(): ?string
+ {
+ if ($this->base64Data === null) {
+ return null;
+ }
+ return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data);
+ }
+ /**
+ * Gets the MIME type of the file as a string.
+ *
+ * @since 0.1.0
+ *
+ * @return string The MIME type string value.
+ */
+ public function getMimeType(): string
+ {
+ return (string) $this->mimeType;
+ }
+ /**
+ * Gets the MIME type object.
+ *
+ * @since 0.1.0
+ *
+ * @return MimeType The MIME type object.
+ */
+ public function getMimeTypeObject(): MimeType
+ {
+ return $this->mimeType;
+ }
+ /**
+ * Checks if the file is a video.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is a video.
+ */
+ public function isVideo(): bool
+ {
+ return $this->mimeType->isVideo();
+ }
+ /**
+ * Checks if the file is an image.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is an image.
+ */
+ public function isImage(): bool
+ {
+ return $this->mimeType->isImage();
+ }
+ /**
+ * Checks if the file is audio.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is audio.
+ */
+ public function isAudio(): bool
+ {
+ return $this->mimeType->isAudio();
+ }
+ /**
+ * Checks if the file is text.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is text.
+ */
+ public function isText(): bool
+ {
+ return $this->mimeType->isText();
+ }
+ /**
+ * Checks if the file is a document.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the file is a document.
+ */
+ public function isDocument(): bool
+ {
+ return $this->mimeType->isDocument();
+ }
+ /**
+ * Checks if the file is a specific MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @param string $type The mime type to check (e.g. 'image', 'text', 'video', 'audio').
+ *
+ * @return bool True if the file is of the specified type.
+ */
+ public function isMimeType(string $type): bool
+ {
+ return $this->mimeType->isType($type);
+ }
+ /**
+ * Determines the MIME type from various sources.
+ *
+ * @since 0.1.0
+ *
+ * @param string|null $providedMimeType The explicitly provided MIME type.
+ * @param string|null $extractedMimeType The MIME type extracted from data URI.
+ * @param string|null $pathOrUrl The file path or URL to extract extension from.
+ * @return MimeType The determined MIME type.
+ * @throws InvalidArgumentException If MIME type cannot be determined.
+ */
+ private function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType
+ {
+ // Prefer explicitly provided MIME type
+ if ($providedMimeType !== null) {
+ return new MimeType($providedMimeType);
+ }
+ // Use extracted MIME type from data URI
+ if ($extractedMimeType !== null) {
+ return new MimeType($extractedMimeType);
+ }
+ // Try to determine from file extension
+ if ($pathOrUrl !== null) {
+ $parsedUrl = parse_url($pathOrUrl);
+ $path = $parsedUrl['path'] ?? $pathOrUrl;
+ // Remove query string and fragment if present
+ $cleanPath = strtok($path, '?#');
+ if ($cleanPath === \false) {
+ $cleanPath = $path;
+ }
+ $extension = pathinfo($cleanPath, \PATHINFO_EXTENSION);
+ if (!empty($extension)) {
+ try {
+ return MimeType::fromExtension($extension);
+ } catch (InvalidArgumentException $e) {
+ // Extension not recognized, continue to error
+ unset($e);
+ }
+ }
+ }
+ throw new InvalidArgumentException('Unable to determine MIME type. Please provide it explicitly.');
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'oneOf' => [['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_URL => ['type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL]], ['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_BASE64_DATA => ['type' => 'string', 'description' => 'The base64-encoded file data.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA]]]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return FileArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_FILE_TYPE => $this->fileType->value, self::KEY_MIME_TYPE => $this->getMimeType()];
+ if ($this->url !== null) {
+ $data[self::KEY_URL] = $this->url;
+ } elseif (!$this->fileType->isRemote() && $this->base64Data !== null) {
+ $data[self::KEY_BASE64_DATA] = $this->base64Data;
+ } else {
+ throw new RuntimeException('File requires either url or base64Data. This should not be a possible condition.');
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_FILE_TYPE]);
+ // Check which properties are set to determine how to construct the File
+ $mimeType = $array[self::KEY_MIME_TYPE] ?? null;
+ if (isset($array[self::KEY_URL])) {
+ return new self($array[self::KEY_URL], $mimeType);
+ } elseif (isset($array[self::KEY_BASE64_DATA])) {
+ return new self($array[self::KEY_BASE64_DATA], $mimeType);
+ } else {
+ throw new InvalidArgumentException('File requires either url or base64Data.');
+ }
+ }
+ /**
+ * Performs a deep clone of the file.
+ *
+ * This method ensures that the MimeType value object is cloned to prevent
+ * any shared references between the original and cloned file.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $this->mimeType = clone $this->mimeType;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php b/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php
new file mode 100644
index 0000000000000..0f50ff93fa39f
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php
@@ -0,0 +1,31 @@
+
+ */
+ private static array $extensionMap = [
+ // Text
+ 'txt' => 'text/plain',
+ 'html' => 'text/html',
+ 'htm' => 'text/html',
+ 'css' => 'text/css',
+ 'js' => 'application/javascript',
+ 'json' => 'application/json',
+ 'xml' => 'application/xml',
+ 'csv' => 'text/csv',
+ 'md' => 'text/markdown',
+ // Images
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'bmp' => 'image/bmp',
+ 'webp' => 'image/webp',
+ 'svg' => 'image/svg+xml',
+ 'ico' => 'image/x-icon',
+ // Documents
+ 'pdf' => 'application/pdf',
+ 'doc' => 'application/msword',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'xls' => 'application/vnd.ms-excel',
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'ppt' => 'application/vnd.ms-powerpoint',
+ 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'odt' => 'application/vnd.oasis.opendocument.text',
+ 'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
+ // Archives
+ 'zip' => 'application/zip',
+ 'tar' => 'application/x-tar',
+ 'gz' => 'application/gzip',
+ 'rar' => 'application/x-rar-compressed',
+ '7z' => 'application/x-7z-compressed',
+ // Audio
+ 'mp3' => 'audio/mpeg',
+ 'wav' => 'audio/wav',
+ 'ogg' => 'audio/ogg',
+ 'flac' => 'audio/flac',
+ 'm4a' => 'audio/m4a',
+ 'aac' => 'audio/aac',
+ // Video
+ 'mp4' => 'video/mp4',
+ 'avi' => 'video/x-msvideo',
+ 'mov' => 'video/quicktime',
+ 'wmv' => 'video/x-ms-wmv',
+ 'flv' => 'video/x-flv',
+ 'webm' => 'video/webm',
+ 'mkv' => 'video/x-matroska',
+ // Fonts
+ 'ttf' => 'font/ttf',
+ 'otf' => 'font/otf',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ // Other
+ 'php' => 'application/x-httpd-php',
+ 'sh' => 'application/x-sh',
+ 'exe' => 'application/x-msdownload',
+ ];
+ /**
+ * Document MIME types.
+ *
+ * @var array
+ */
+ private static array $documentTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet'];
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $value The MIME type value.
+ * @throws InvalidArgumentException If the MIME type is invalid.
+ */
+ public function __construct(string $value)
+ {
+ if (!self::isValid($value)) {
+ throw new InvalidArgumentException(sprintf('Invalid MIME type: %s', $value));
+ }
+ $this->value = strtolower($value);
+ }
+ /**
+ * Gets the primary known file extension for this MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return string The file extension (without the dot).
+ * @throws InvalidArgumentException If no known extension exists for this MIME type.
+ */
+ public function toExtension(): string
+ {
+ // Reverse lookup for the MIME type to find the extension.
+ $extension = array_search($this->value, self::$extensionMap, \true);
+ if ($extension === \false) {
+ throw new InvalidArgumentException(sprintf('No known extension for MIME type: %s', $this->value));
+ }
+ return $extension;
+ }
+ /**
+ * Creates a MimeType from a file extension.
+ *
+ * @since 0.1.0
+ *
+ * @param string $extension The file extension (without the dot).
+ * @return self The MimeType instance.
+ * @throws InvalidArgumentException If the extension is not recognized.
+ */
+ public static function fromExtension(string $extension): self
+ {
+ $extension = strtolower($extension);
+ if (!isset(self::$extensionMap[$extension])) {
+ throw new InvalidArgumentException(sprintf('Unknown file extension: %s', $extension));
+ }
+ return new self(self::$extensionMap[$extension]);
+ }
+ /**
+ * Checks if a MIME type string is valid.
+ *
+ * @since 0.1.0
+ *
+ * @param string $mimeType The MIME type to validate.
+ * @return bool True if valid.
+ */
+ public static function isValid(string $mimeType): bool
+ {
+ // Basic MIME type validation: type/subtype
+ return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', $mimeType);
+ }
+ /**
+ * Checks if this MIME type is a specific type.
+ *
+ * This method returns true when the stored MIME type begins with the
+ * given prefix. For example, `"audio"` matches `"audio/mpeg"`.
+ *
+ * @since 0.1.0
+ *
+ * @param string $mimeType The MIME type prefix to check (e.g., "audio", "image").
+ * @return bool True if this MIME type is of the specified type.
+ */
+ public function isType(string $mimeType): bool
+ {
+ return str_starts_with($this->value, strtolower($mimeType) . '/');
+ }
+ /**
+ * Checks if this is an image MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if this is an image type.
+ */
+ public function isImage(): bool
+ {
+ return $this->isType('image');
+ }
+ /**
+ * Checks if this is an audio MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if this is an audio type.
+ */
+ public function isAudio(): bool
+ {
+ return $this->isType('audio');
+ }
+ /**
+ * Checks if this is a video MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if this is a video type.
+ */
+ public function isVideo(): bool
+ {
+ return $this->isType('video');
+ }
+ /**
+ * Checks if this is a text MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if this is a text type.
+ */
+ public function isText(): bool
+ {
+ return $this->isType('text');
+ }
+ /**
+ * Checks if this is a document MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if this is a document type.
+ */
+ public function isDocument(): bool
+ {
+ return in_array($this->value, self::$documentTypes, \true);
+ }
+ /**
+ * Checks if this MIME type equals another.
+ *
+ * @since 0.1.0
+ *
+ * @param self|string $other The other MIME type to compare.
+ * @return bool True if equal.
+ * @throws InvalidArgumentException If the other MIME type is invalid.
+ */
+ public function equals($other): bool
+ {
+ if ($other instanceof self) {
+ return $this->value === $other->value;
+ }
+ if (is_string($other)) {
+ return $this->value === strtolower($other);
+ }
+ throw new InvalidArgumentException(sprintf('Invalid MIME type comparison: %s', gettype($other)));
+ }
+ /**
+ * Gets the string representation of the MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return string The MIME type value.
+ */
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php b/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php
new file mode 100644
index 0000000000000..290685a58854e
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php
@@ -0,0 +1,173 @@
+
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class Message extends AbstractDataTransferObject
+{
+ public const KEY_ROLE = 'role';
+ public const KEY_PARTS = 'parts';
+ /**
+ * @var MessageRoleEnum The role of the message sender.
+ */
+ protected MessageRoleEnum $role;
+ /**
+ * @var MessagePart[] The parts that make up this message.
+ */
+ protected array $parts;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param MessageRoleEnum $role The role of the message sender.
+ * @param MessagePart[] $parts The parts that make up this message.
+ * @throws InvalidArgumentException If parts contain invalid content for the role.
+ */
+ public function __construct(MessageRoleEnum $role, array $parts)
+ {
+ $this->role = $role;
+ $this->parts = $parts;
+ $this->validateParts();
+ }
+ /**
+ * Gets the role of the message sender.
+ *
+ * @since 0.1.0
+ *
+ * @return MessageRoleEnum The role.
+ */
+ public function getRole(): MessageRoleEnum
+ {
+ return $this->role;
+ }
+ /**
+ * Gets the message parts.
+ *
+ * @since 0.1.0
+ *
+ * @return MessagePart[] The message parts.
+ */
+ public function getParts(): array
+ {
+ return $this->parts;
+ }
+ /**
+ * Returns a new instance with the given part appended.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart $part The part to append.
+ * @return Message A new instance with the part appended.
+ * @throws InvalidArgumentException If the part is invalid for the role.
+ */
+ public function withPart(\WordPress\AiClient\Messages\DTO\MessagePart $part): \WordPress\AiClient\Messages\DTO\Message
+ {
+ $newParts = $this->parts;
+ $newParts[] = $part;
+ return new \WordPress\AiClient\Messages\DTO\Message($this->role, $newParts);
+ }
+ /**
+ * Validates that the message parts are appropriate for the message role.
+ *
+ * @since 0.1.0
+ *
+ * @return void
+ * @throws InvalidArgumentException If validation fails.
+ */
+ private function validateParts(): void
+ {
+ foreach ($this->parts as $part) {
+ $type = $part->getType();
+ if ($this->role->isUser() && $type->isFunctionCall()) {
+ throw new InvalidArgumentException('User messages cannot contain function calls.');
+ }
+ if ($this->role->isModel() && $type->isFunctionResponse()) {
+ throw new InvalidArgumentException('Model messages cannot contain function responses.');
+ }
+ }
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ROLE => ['type' => 'string', 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.'], self::KEY_PARTS => ['type' => 'array', 'items' => \WordPress\AiClient\Messages\DTO\MessagePart::getJsonSchema(), 'minItems' => 1, 'description' => 'The parts that make up this message.']], 'required' => [self::KEY_ROLE, self::KEY_PARTS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return MessageArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ROLE => $this->role->value, self::KEY_PARTS => array_map(function (\WordPress\AiClient\Messages\DTO\MessagePart $part) {
+ return $part->toArray();
+ }, $this->parts)];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return self The specific message class based on the role.
+ */
+ final public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_ROLE, self::KEY_PARTS]);
+ $role = MessageRoleEnum::from($array[self::KEY_ROLE]);
+ $partsData = $array[self::KEY_PARTS];
+ $parts = array_map(function (array $partData) {
+ return \WordPress\AiClient\Messages\DTO\MessagePart::fromArray($partData);
+ }, $partsData);
+ // Determine which concrete class to instantiate based on role
+ if ($role->isUser()) {
+ return new \WordPress\AiClient\Messages\DTO\UserMessage($parts);
+ } elseif ($role->isModel()) {
+ return new \WordPress\AiClient\Messages\DTO\ModelMessage($parts);
+ } else {
+ // Only USER and MODEL roles are supported
+ throw new InvalidArgumentException('Invalid message role: ' . $role->value);
+ }
+ }
+ /**
+ * Performs a deep clone of the message.
+ *
+ * This method ensures that message part objects are cloned to prevent
+ * modifications to the cloned message from affecting the original.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $clonedParts = [];
+ foreach ($this->parts as $part) {
+ $clonedParts[] = clone $part;
+ }
+ $this->parts = $clonedParts;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
new file mode 100644
index 0000000000000..6728fd81cf697
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php
@@ -0,0 +1,242 @@
+
+ */
+class MessagePart extends AbstractDataTransferObject
+{
+ public const KEY_CHANNEL = 'channel';
+ public const KEY_TYPE = 'type';
+ public const KEY_TEXT = 'text';
+ public const KEY_FILE = 'file';
+ public const KEY_FUNCTION_CALL = 'functionCall';
+ public const KEY_FUNCTION_RESPONSE = 'functionResponse';
+ /**
+ * @var MessagePartChannelEnum The channel this message part belongs to.
+ */
+ private MessagePartChannelEnum $channel;
+ /**
+ * @var MessagePartTypeEnum The type of this message part.
+ */
+ private MessagePartTypeEnum $type;
+ /**
+ * @var string|null Text content (when type is TEXT).
+ */
+ private ?string $text = null;
+ /**
+ * @var File|null File data (when type is FILE).
+ */
+ private ?File $file = null;
+ /**
+ * @var FunctionCall|null Function call request (when type is FUNCTION_CALL).
+ */
+ private ?FunctionCall $functionCall = null;
+ /**
+ * @var FunctionResponse|null Function response (when type is FUNCTION_RESPONSE).
+ */
+ private ?FunctionResponse $functionResponse = null;
+ /**
+ * Constructor that accepts various content types and infers the message part type.
+ *
+ * @since 0.1.0
+ *
+ * @param mixed $content The content of this message part.
+ * @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT.
+ * @throws InvalidArgumentException If an unsupported content type is provided.
+ */
+ public function __construct($content, ?MessagePartChannelEnum $channel = null)
+ {
+ $this->channel = $channel ?? MessagePartChannelEnum::content();
+ if (is_string($content)) {
+ $this->type = MessagePartTypeEnum::text();
+ $this->text = $content;
+ } elseif ($content instanceof File) {
+ $this->type = MessagePartTypeEnum::file();
+ $this->file = $content;
+ } elseif ($content instanceof FunctionCall) {
+ $this->type = MessagePartTypeEnum::functionCall();
+ $this->functionCall = $content;
+ } elseif ($content instanceof FunctionResponse) {
+ $this->type = MessagePartTypeEnum::functionResponse();
+ $this->functionResponse = $content;
+ } else {
+ $type = is_object($content) ? get_class($content) : gettype($content);
+ throw new InvalidArgumentException(sprintf('Unsupported content type %s. Expected string, File, ' . 'FunctionCall, or FunctionResponse.', $type));
+ }
+ }
+ /**
+ * Gets the channel this message part belongs to.
+ *
+ * @since 0.1.0
+ *
+ * @return MessagePartChannelEnum The channel.
+ */
+ public function getChannel(): MessagePartChannelEnum
+ {
+ return $this->channel;
+ }
+ /**
+ * Gets the type of this message part.
+ *
+ * @since 0.1.0
+ *
+ * @return MessagePartTypeEnum The type.
+ */
+ public function getType(): MessagePartTypeEnum
+ {
+ return $this->type;
+ }
+ /**
+ * Gets the text content.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The text content or null if not a text part.
+ */
+ public function getText(): ?string
+ {
+ return $this->text;
+ }
+ /**
+ * Gets the file.
+ *
+ * @since 0.1.0
+ *
+ * @return File|null The file or null if not a file part.
+ */
+ public function getFile(): ?File
+ {
+ return $this->file;
+ }
+ /**
+ * Gets the function call.
+ *
+ * @since 0.1.0
+ *
+ * @return FunctionCall|null The function call or null if not a function call part.
+ */
+ public function getFunctionCall(): ?FunctionCall
+ {
+ return $this->functionCall;
+ }
+ /**
+ * Gets the function response.
+ *
+ * @since 0.1.0
+ *
+ * @return FunctionResponse|null The function response or null if not a function response part.
+ */
+ public function getFunctionResponse(): ?FunctionResponse
+ {
+ return $this->functionResponse;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ $channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.'];
+ return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.']], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return MessagePartArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_CHANNEL => $this->channel->value, self::KEY_TYPE => $this->type->value];
+ if ($this->text !== null) {
+ $data[self::KEY_TEXT] = $this->text;
+ } elseif ($this->file !== null) {
+ $data[self::KEY_FILE] = $this->file->toArray();
+ } elseif ($this->functionCall !== null) {
+ $data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray();
+ } elseif ($this->functionResponse !== null) {
+ $data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray();
+ } else {
+ throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.');
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ if (isset($array[self::KEY_CHANNEL])) {
+ $channel = MessagePartChannelEnum::from($array[self::KEY_CHANNEL]);
+ } else {
+ $channel = null;
+ }
+ // Check which properties are set to determine how to construct the MessagePart
+ if (isset($array[self::KEY_TEXT])) {
+ return new self($array[self::KEY_TEXT], $channel);
+ } elseif (isset($array[self::KEY_FILE])) {
+ return new self(File::fromArray($array[self::KEY_FILE]), $channel);
+ } elseif (isset($array[self::KEY_FUNCTION_CALL])) {
+ return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel);
+ } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) {
+ return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel);
+ } else {
+ throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.');
+ }
+ }
+ /**
+ * Performs a deep clone of the message part.
+ *
+ * This method ensures that nested objects (file, function call, function response)
+ * are cloned to prevent modifications to the cloned part from affecting the original.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ if ($this->file !== null) {
+ $this->file = clone $this->file;
+ }
+ if ($this->functionCall !== null) {
+ $this->functionCall = clone $this->functionCall;
+ }
+ if ($this->functionResponse !== null) {
+ $this->functionResponse = clone $this->functionResponse;
+ }
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php b/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php
new file mode 100644
index 0000000000000..e998e46cd8bff
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php
@@ -0,0 +1,32 @@
+getRole()`
+ * to check the role of a message.
+ *
+ * @since 0.1.0
+ */
+class ModelMessage extends \WordPress\AiClient\Messages\DTO\Message
+{
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart[] $parts The parts that make up this message.
+ */
+ public function __construct(array $parts)
+ {
+ parent::__construct(MessageRoleEnum::model(), $parts);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php b/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php
new file mode 100644
index 0000000000000..35e5349ff43f4
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php
@@ -0,0 +1,31 @@
+getRole()`
+ * to check the role of a message.
+ *
+ * @since 0.1.0
+ */
+class UserMessage extends \WordPress\AiClient\Messages\DTO\Message
+{
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart[] $parts The parts that make up this message.
+ */
+ public function __construct(array $parts)
+ {
+ parent::__construct(MessageRoleEnum::user(), $parts);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php b/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php
new file mode 100644
index 0000000000000..5b7cbf56559ba
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php
@@ -0,0 +1,27 @@
+
+ */
+class GenerativeAiOperation extends AbstractDataTransferObject implements OperationInterface
+{
+ public const KEY_ID = 'id';
+ public const KEY_STATE = 'state';
+ public const KEY_RESULT = 'result';
+ /**
+ * @var string Unique identifier for this operation.
+ */
+ private string $id;
+ /**
+ * @var OperationStateEnum The current state of the operation.
+ */
+ private OperationStateEnum $state;
+ /**
+ * @var GenerativeAiResult|null The result once the operation completes.
+ */
+ private ?GenerativeAiResult $result;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $id Unique identifier for this operation.
+ * @param OperationStateEnum $state The current state of the operation.
+ * @param GenerativeAiResult|null $result The result once the operation completes.
+ */
+ public function __construct(string $id, OperationStateEnum $state, ?GenerativeAiResult $result = null)
+ {
+ $this->id = $id;
+ $this->state = $state;
+ $this->result = $result;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getState(): OperationStateEnum
+ {
+ return $this->state;
+ }
+ /**
+ * Gets the operation result.
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResult|null The result or null if not yet complete.
+ */
+ public function getResult(): ?GenerativeAiResult
+ {
+ return $this->result;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['oneOf' => [
+ // Succeeded state - has result
+ ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'const' => OperationStateEnum::succeeded()->value], self::KEY_RESULT => GenerativeAiResult::getJsonSchema()], 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], 'additionalProperties' => \false],
+ // All other states - no result
+ ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'enum' => [OperationStateEnum::starting()->value, OperationStateEnum::processing()->value, OperationStateEnum::failed()->value, OperationStateEnum::canceled()->value], 'description' => 'The current state of the operation.']], 'required' => [self::KEY_ID, self::KEY_STATE], 'additionalProperties' => \false],
+ ]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiOperationArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_ID => $this->id, self::KEY_STATE => $this->state->value];
+ if ($this->result !== null) {
+ $data[self::KEY_RESULT] = $this->result->toArray();
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_ID, self::KEY_STATE]);
+ $state = OperationStateEnum::from($array[self::KEY_STATE]);
+ if ($state->isSucceeded()) {
+ // If the operation has succeeded, it must have a result
+ static::validateFromArrayData($array, [self::KEY_RESULT]);
+ }
+ $result = null;
+ if (isset($array[self::KEY_RESULT])) {
+ $result = GenerativeAiResult::fromArray($array[self::KEY_RESULT]);
+ }
+ return new self($array[self::KEY_ID], $state, $result);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php b/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php
new file mode 100644
index 0000000000000..034cea04b3fe8
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php
@@ -0,0 +1,45 @@
+ Cache for provider metadata per class.
+ */
+ private static array $metadataCache = [];
+ /**
+ * @var array Cache for provider availability per class.
+ */
+ private static array $availabilityCache = [];
+ /**
+ * @var array Cache for model metadata directory per class.
+ */
+ private static array $modelMetadataDirectoryCache = [];
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public static function metadata(): ProviderMetadata
+ {
+ $className = static::class;
+ if (!isset(self::$metadataCache[$className])) {
+ self::$metadataCache[$className] = static::createProviderMetadata();
+ }
+ return self::$metadataCache[$className];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
+ {
+ $providerMetadata = static::metadata();
+ $modelMetadata = static::modelMetadataDirectory()->getModelMetadata($modelId);
+ $model = static::createModel($modelMetadata, $providerMetadata);
+ if ($modelConfig) {
+ $model->setConfig($modelConfig);
+ }
+ return $model;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public static function availability(): ProviderAvailabilityInterface
+ {
+ $className = static::class;
+ if (!isset(self::$availabilityCache[$className])) {
+ self::$availabilityCache[$className] = static::createProviderAvailability();
+ }
+ return self::$availabilityCache[$className];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface
+ {
+ $className = static::class;
+ if (!isset(self::$modelMetadataDirectoryCache[$className])) {
+ self::$modelMetadataDirectoryCache[$className] = static::createModelMetadataDirectory();
+ }
+ return self::$modelMetadataDirectoryCache[$className];
+ }
+ /**
+ * Creates a model instance based on the given model metadata and provider metadata.
+ *
+ * @since 0.1.0
+ *
+ * @param ModelMetadata $modelMetadata The model metadata.
+ * @param ProviderMetadata $providerMetadata The provider metadata.
+ * @return ModelInterface The new model instance.
+ */
+ abstract protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface;
+ /**
+ * Creates the provider metadata instance.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderMetadata The provider metadata.
+ */
+ abstract protected static function createProviderMetadata(): ProviderMetadata;
+ /**
+ * Creates the provider availability instance.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderAvailabilityInterface The provider availability.
+ */
+ abstract protected static function createProviderAvailability(): ProviderAvailabilityInterface;
+ /**
+ * Creates the model metadata directory instance.
+ *
+ * @since 0.1.0
+ *
+ * @return ModelMetadataDirectoryInterface The model metadata directory.
+ */
+ abstract protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php
new file mode 100644
index 0000000000000..30705e64cb37c
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php
@@ -0,0 +1,111 @@
+metadata = $metadata;
+ $this->providerMetadata = $providerMetadata;
+ $this->config = ModelConfig::fromArray([]);
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function metadata(): ModelMetadata
+ {
+ return $this->metadata;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function providerMetadata(): ProviderMetadata
+ {
+ return $this->providerMetadata;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function setConfig(ModelConfig $config): void
+ {
+ $this->config = $config;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function getConfig(): ModelConfig
+ {
+ return $this->config;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.3.0
+ */
+ final public function setRequestOptions(RequestOptions $requestOptions): void
+ {
+ $this->requestOptions = $requestOptions;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.3.0
+ */
+ final public function getRequestOptions(): ?RequestOptions
+ {
+ return $this->requestOptions;
+ }
+}
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
new file mode 100644
index 0000000000000..4f7e2a338fabc
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php
@@ -0,0 +1,105 @@
+getModelMetadataMap();
+ return array_values($modelsMetadata);
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function hasModelMetadata(string $modelId): bool
+ {
+ $modelsMetadata = $this->getModelMetadataMap();
+ return isset($modelsMetadata[$modelId]);
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function getModelMetadata(string $modelId): ModelMetadata
+ {
+ $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];
+ }
+ /**
+ * Returns the map of model ID to model metadata for all models from the provider.
+ *
+ * @since 0.1.0
+ *
+ * @return array Map of model ID to model metadata.
+ */
+ private function getModelMetadataMap(): array
+ {
+ /** @var array */
+ return $this->cached(self::MODELS_CACHE_KEY, fn() => $this->sendListModelsRequest(), 86400);
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.4.0
+ */
+ protected function getCachedKeys(): array
+ {
+ return [self::MODELS_CACHE_KEY];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.4.0
+ */
+ protected function getBaseCacheKey(): string
+ {
+ return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class);
+ }
+ /**
+ * Sends the API request to list models from the provider and returns the map of model ID to model metadata.
+ *
+ * @since 0.1.0
+ *
+ * @return array Map of model ID to model metadata.
+ */
+ abstract protected function sendListModelsRequest(): array;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php
new file mode 100644
index 0000000000000..70a84873a2323
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php
@@ -0,0 +1,49 @@
+model = $model;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function isConfigured(): bool
+ {
+ // Set config to use as few resources as possible for the test.
+ $modelConfig = ModelConfig::fromArray([ModelConfig::KEY_MAX_TOKENS => 1]);
+ $this->model->setConfig($modelConfig);
+ try {
+ // Attempt to generate text to check if the provider is available.
+ $this->model->generateTextResult([new Message(MessageRoleEnum::user(), [new MessagePart('a')])]);
+ return \true;
+ } catch (Exception $e) {
+ // If an exception occurs, the provider is not available.
+ return \false;
+ }
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php
new file mode 100644
index 0000000000000..128184e737df8
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php
@@ -0,0 +1,52 @@
+modelMetadataDirectory = $modelMetadataDirectory;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function isConfigured(): bool
+ {
+ try {
+ // Attempt to list models to check if the provider is available.
+ $this->modelMetadataDirectory->listModelMetadata();
+ return \true;
+ } catch (Exception $e) {
+ // If an exception occurs, the provider is not available.
+ return \false;
+ }
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php b/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php
new file mode 100644
index 0000000000000..52be8c357c0ff
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php
@@ -0,0 +1,45 @@
+ Array of model metadata.
+ */
+ public function listModelMetadata(): array;
+ /**
+ * Checks if metadata exists for a specific model.
+ *
+ * @since 0.1.0
+ *
+ * @param string $modelId Model identifier.
+ * @return bool True if metadata exists, false otherwise.
+ */
+ public function hasModelMetadata(string $modelId): bool;
+ /**
+ * Gets metadata for a specific model.
+ *
+ * @since 0.1.0
+ *
+ * @param string $modelId Model identifier.
+ * @return ModelMetadata Model metadata.
+ * @throws InvalidArgumentException If model metadata not found.
+ */
+ public function getModelMetadata(string $modelId): ModelMetadata;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php b/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php
new file mode 100644
index 0000000000000..a5b2737fdb48b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php
@@ -0,0 +1,24 @@
+
+ */
+class ProviderMetadata extends AbstractDataTransferObject
+{
+ public const KEY_ID = 'id';
+ public const KEY_NAME = 'name';
+ public const KEY_TYPE = 'type';
+ public const KEY_CREDENTIALS_URL = 'credentialsUrl';
+ public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod';
+ /**
+ * @var string The provider's unique identifier.
+ */
+ protected string $id;
+ /**
+ * @var string The provider's display name.
+ */
+ protected string $name;
+ /**
+ * @var ProviderTypeEnum The provider type.
+ */
+ protected ProviderTypeEnum $type;
+ /**
+ * @var string|null The URL where users can get credentials.
+ */
+ protected ?string $credentialsUrl;
+ /**
+ * @var RequestAuthenticationMethod|null The authentication method.
+ */
+ protected ?RequestAuthenticationMethod $authenticationMethod;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $id The provider's unique identifier.
+ * @param string $name The provider's display name.
+ * @param ProviderTypeEnum $type The provider type.
+ * @param string|null $credentialsUrl The URL where users can get credentials.
+ * @param RequestAuthenticationMethod|null $authenticationMethod The authentication method.
+ */
+ public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null)
+ {
+ $this->id = $id;
+ $this->name = $name;
+ $this->type = $type;
+ $this->credentialsUrl = $credentialsUrl;
+ $this->authenticationMethod = $authenticationMethod;
+ }
+ /**
+ * Gets the provider's unique identifier.
+ *
+ * @since 0.1.0
+ *
+ * @return string The provider ID.
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+ /**
+ * Gets the provider's display name.
+ *
+ * @since 0.1.0
+ *
+ * @return string The provider name.
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the provider type.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderTypeEnum The provider type.
+ */
+ public function getType(): ProviderTypeEnum
+ {
+ return $this->type;
+ }
+ /**
+ * Gets the credentials URL.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The credentials URL.
+ */
+ public function getCredentialsUrl(): ?string
+ {
+ return $this->credentialsUrl;
+ }
+ /**
+ * Gets the authentication method.
+ *
+ * @since 0.4.0
+ *
+ * @return RequestAuthenticationMethod|null The authentication method.
+ */
+ public function getAuthenticationMethod(): ?RequestAuthenticationMethod
+ {
+ return $this->authenticationMethod;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderMetadataArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]);
+ return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php
new file mode 100644
index 0000000000000..29d66cab05ec5
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php
@@ -0,0 +1,109 @@
+
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class ProviderModelsMetadata extends AbstractDataTransferObject
+{
+ public const KEY_PROVIDER = 'provider';
+ public const KEY_MODELS = 'models';
+ /**
+ * @var ProviderMetadata The provider metadata.
+ */
+ protected \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider;
+ /**
+ * @var list The available models.
+ */
+ protected array $models;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param ProviderMetadata $provider The provider metadata.
+ * @param list $models The available models.
+ *
+ * @throws InvalidArgumentException If models is not a list.
+ */
+ public function __construct(\WordPress\AiClient\Providers\DTO\ProviderMetadata $provider, array $models)
+ {
+ if (!array_is_list($models)) {
+ throw new InvalidArgumentException('Models must be a list array.');
+ }
+ $this->provider = $provider;
+ $this->models = $models;
+ }
+ /**
+ * Gets the provider metadata.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderMetadata The provider metadata.
+ */
+ public function getProvider(): \WordPress\AiClient\Providers\DTO\ProviderMetadata
+ {
+ return $this->provider;
+ }
+ /**
+ * Gets the available models.
+ *
+ * @since 0.1.0
+ *
+ * @return list The available models.
+ */
+ public function getModels(): array
+ {
+ return $this->models;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_PROVIDER => \WordPress\AiClient\Providers\DTO\ProviderMetadata::getJsonSchema(), self::KEY_MODELS => ['type' => 'array', 'items' => ModelMetadata::getJsonSchema(), 'description' => 'The available models for this provider.']], 'required' => [self::KEY_PROVIDER, self::KEY_MODELS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderModelsMetadataArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_PROVIDER => $this->provider->toArray(), self::KEY_MODELS => array_map(static fn(ModelMetadata $model): array => $model->toArray(), $this->models)];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_PROVIDER, self::KEY_MODELS]);
+ return new self(\WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray($array[self::KEY_PROVIDER]), array_map(static fn(array $modelData): ModelMetadata => ModelMetadata::fromArray($modelData), $array[self::KEY_MODELS]));
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php b/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php
new file mode 100644
index 0000000000000..c074f673b27bd
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php
@@ -0,0 +1,33 @@
+> The headers with original casing.
+ */
+ private array $headers = [];
+ /**
+ * @var array Map of lowercase header names to actual header names.
+ */
+ private array $headersMap = [];
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param array> $headers Initial headers.
+ */
+ public function __construct(array $headers = [])
+ {
+ foreach ($headers as $name => $value) {
+ $this->set($name, $value);
+ }
+ }
+ /**
+ * Gets a specific header value.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return list|null The header value(s) or null if not found.
+ */
+ public function get(string $name): ?array
+ {
+ $lowerName = strtolower($name);
+ if (!isset($this->headersMap[$lowerName])) {
+ return null;
+ }
+ $actualName = $this->headersMap[$lowerName];
+ return $this->headers[$actualName];
+ }
+ /**
+ * Gets all headers.
+ *
+ * @since 0.1.0
+ *
+ * @return array> All headers with their original casing.
+ */
+ public function getAll(): array
+ {
+ return $this->headers;
+ }
+ /**
+ * Gets header values as a comma-separated string.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return string|null The header values as a comma-separated string or null if not found.
+ */
+ public function getAsString(string $name): ?string
+ {
+ $values = $this->get($name);
+ return $values !== null ? implode(', ', $values) : null;
+ }
+ /**
+ * Checks if a header exists.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return bool True if the header exists, false otherwise.
+ */
+ public function has(string $name): bool
+ {
+ return isset($this->headersMap[strtolower($name)]);
+ }
+ /**
+ * Sets a header value, replacing any existing value.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name.
+ * @param string|list $value The header value(s).
+ * @return void
+ */
+ private function set(string $name, $value): void
+ {
+ if (is_array($value)) {
+ $normalizedValues = array_values($value);
+ } else {
+ // Split comma-separated string into array
+ $normalizedValues = array_map('trim', explode(',', $value));
+ }
+ $lowerName = strtolower($name);
+ // If header exists with different casing, remove the old casing
+ if (isset($this->headersMap[$lowerName])) {
+ $oldName = $this->headersMap[$lowerName];
+ if ($oldName !== $name) {
+ unset($this->headers[$oldName]);
+ }
+ }
+ // Always use the new casing
+ $this->headers[$name] = $normalizedValues;
+ $this->headersMap[$lowerName] = $name;
+ }
+ /**
+ * Returns a new instance with the specified header.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name.
+ * @param string|list $value The header value(s).
+ * @return self A new instance with the header.
+ */
+ public function withHeader(string $name, $value): self
+ {
+ $new = clone $this;
+ $new->set($name, $value);
+ return $new;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php
new file mode 100644
index 0000000000000..b6a088725f3d5
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php
@@ -0,0 +1,29 @@
+
+ */
+class ApiKeyRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface
+{
+ public const KEY_API_KEY = 'apiKey';
+ /**
+ * @var string The API key used for authentication.
+ */
+ protected string $apiKey;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $apiKey The API key used for authentication.
+ */
+ public function __construct(string $apiKey)
+ {
+ $this->apiKey = $apiKey;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function authenticateRequest(\WordPress\AiClient\Providers\Http\DTO\Request $request): \WordPress\AiClient\Providers\Http\DTO\Request
+ {
+ // Add the API key to the request headers.
+ return $request->withHeader('Authorization', 'Bearer ' . $this->apiKey);
+ }
+ /**
+ * Gets the API key.
+ *
+ * @since 0.1.0
+ *
+ * @return string The API key.
+ */
+ public function getApiKey(): string
+ {
+ return $this->apiKey;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @since 0.1.0
+ *
+ * @return ApiKeyRequestAuthenticationArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_API_KEY => $this->apiKey];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_API_KEY]);
+ return new self($array[self::KEY_API_KEY]);
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_API_KEY => ['type' => 'string', 'title' => 'API Key', 'description' => 'The API key used for authentication.']], 'required' => [self::KEY_API_KEY]];
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php
new file mode 100644
index 0000000000000..8d62f01746632
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php
@@ -0,0 +1,358 @@
+>,
+ * body?: string|null,
+ * options?: RequestOptionsArrayShape
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class Request extends AbstractDataTransferObject
+{
+ public const KEY_METHOD = 'method';
+ public const KEY_URI = 'uri';
+ public const KEY_HEADERS = 'headers';
+ public const KEY_BODY = 'body';
+ public const KEY_OPTIONS = 'options';
+ /**
+ * @var HttpMethodEnum The HTTP method.
+ */
+ protected HttpMethodEnum $method;
+ /**
+ * @var string The request URI.
+ */
+ protected string $uri;
+ /**
+ * @var HeadersCollection The request headers.
+ */
+ protected HeadersCollection $headers;
+ /**
+ * @var array|null The request data (for query params or form data).
+ */
+ protected ?array $data = null;
+ /**
+ * @var string|null The request body (raw string content).
+ */
+ protected ?string $body = null;
+ /**
+ * @var RequestOptions|null Request transport options.
+ */
+ protected ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param HttpMethodEnum $method The HTTP method.
+ * @param string $uri The request URI.
+ * @param array> $headers The request headers.
+ * @param string|array|null $data The request data.
+ * @param RequestOptions|null $options The request transport options.
+ *
+ * @throws InvalidArgumentException If the URI is empty.
+ */
+ public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null, ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null)
+ {
+ if (empty($uri)) {
+ throw new InvalidArgumentException('URI cannot be empty.');
+ }
+ $this->method = $method;
+ $this->uri = $uri;
+ $this->headers = new HeadersCollection($headers);
+ // Separate data and body based on type
+ if (is_string($data)) {
+ $this->body = $data;
+ } elseif (is_array($data)) {
+ $this->data = $data;
+ }
+ $this->options = $options;
+ }
+ /**
+ * Gets the HTTP method.
+ *
+ * @since 0.1.0
+ *
+ * @return HttpMethodEnum The HTTP method.
+ */
+ public function getMethod(): HttpMethodEnum
+ {
+ return $this->method;
+ }
+ /**
+ * Gets the request URI.
+ *
+ * For GET requests with array data, appends the data as query parameters.
+ *
+ * @since 0.1.0
+ *
+ * @return string The URI.
+ */
+ public function getUri(): string
+ {
+ // If GET request with data, append as query parameters
+ if ($this->method === HttpMethodEnum::GET() && $this->data !== null && !empty($this->data)) {
+ $separator = str_contains($this->uri, '?') ? '&' : '?';
+ return $this->uri . $separator . http_build_query($this->data);
+ }
+ return $this->uri;
+ }
+ /**
+ * Gets the request headers.
+ *
+ * @since 0.1.0
+ *
+ * @return array> The headers.
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers->getAll();
+ }
+ /**
+ * Gets a specific header value.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return list|null The header value(s) or null if not found.
+ */
+ public function getHeader(string $name): ?array
+ {
+ return $this->headers->get($name);
+ }
+ /**
+ * Gets header values as a comma-separated string.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return string|null The header values as a comma-separated string, or null if not found.
+ */
+ public function getHeaderAsString(string $name): ?string
+ {
+ return $this->headers->getAsString($name);
+ }
+ /**
+ * Checks if a header exists.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return bool True if the header exists, false otherwise.
+ */
+ public function hasHeader(string $name): bool
+ {
+ return $this->headers->has($name);
+ }
+ /**
+ * Gets the request body.
+ *
+ * For GET requests, returns null.
+ * For POST/PUT/PATCH requests:
+ * - If body is set, returns it as-is
+ * - If data is set and Content-Type is JSON, returns JSON-encoded data
+ * - If data is set and Content-Type is form, returns URL-encoded data
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The body.
+ * @throws JsonException If the data cannot be encoded to JSON.
+ */
+ public function getBody(): ?string
+ {
+ // GET requests don't have a body
+ if (!$this->method->hasBody()) {
+ return null;
+ }
+ // If body is set, return it as-is
+ if ($this->body !== null) {
+ return $this->body;
+ }
+ // If data is set, encode based on content type
+ if ($this->data !== null) {
+ $contentType = $this->getContentType();
+ // JSON encoding
+ if ($contentType !== null && stripos($contentType, 'application/json') !== \false) {
+ return json_encode($this->data, \JSON_THROW_ON_ERROR);
+ }
+ // Default to URL encoding for forms
+ return http_build_query($this->data);
+ }
+ return null;
+ }
+ /**
+ * Gets the Content-Type header value.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The Content-Type header value or null if not set.
+ */
+ private function getContentType(): ?string
+ {
+ $values = $this->getHeader('Content-Type');
+ return $values !== null ? $values[0] : null;
+ }
+ /**
+ * Returns a new instance with the specified header.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name.
+ * @param string|list $value The header value(s).
+ * @return self A new instance with the header.
+ */
+ public function withHeader(string $name, $value): self
+ {
+ $newHeaders = $this->headers->withHeader($name, $value);
+ $new = clone $this;
+ $new->headers = $newHeaders;
+ return $new;
+ }
+ /**
+ * Returns a new instance with the specified data.
+ *
+ * @since 0.1.0
+ *
+ * @param string|array $data The request data.
+ * @return self A new instance with the data.
+ */
+ public function withData($data): self
+ {
+ $new = clone $this;
+ if (is_string($data)) {
+ $new->body = $data;
+ $new->data = null;
+ } elseif (is_array($data)) {
+ $new->data = $data;
+ $new->body = null;
+ } else {
+ $new->data = null;
+ $new->body = null;
+ }
+ return $new;
+ }
+ /**
+ * Gets the request data array.
+ *
+ * @since 0.1.0
+ *
+ * @return array|null The request data array.
+ */
+ public function getData(): ?array
+ {
+ return $this->data;
+ }
+ /**
+ * Gets the request options.
+ *
+ * @since 0.2.0
+ *
+ * @return RequestOptions|null Request transport options when configured.
+ */
+ public function getOptions(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions
+ {
+ return $this->options;
+ }
+ /**
+ * Returns a new instance with the specified request options.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestOptions|null $options The request options to apply.
+ * @return self A new instance with the options.
+ */
+ public function withOptions(?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options): self
+ {
+ $new = clone $this;
+ $new->options = $options;
+ return $new;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_METHOD => ['type' => 'string', 'description' => 'The HTTP method.'], self::KEY_URI => ['type' => 'string', 'description' => 'The request URI.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The request headers.'], self::KEY_BODY => ['type' => ['string'], 'description' => 'The request body.'], self::KEY_OPTIONS => \WordPress\AiClient\Providers\Http\DTO\RequestOptions::getJsonSchema()], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return RequestArrayShape
+ */
+ public function toArray(): array
+ {
+ $array = [
+ self::KEY_METHOD => $this->method->value,
+ self::KEY_URI => $this->getUri(),
+ // Include query params if GET with data
+ self::KEY_HEADERS => $this->headers->getAll(),
+ ];
+ // Include body if present (getBody() handles the conversion)
+ $body = $this->getBody();
+ if ($body !== null) {
+ $array[self::KEY_BODY] = $body;
+ }
+ if ($this->options !== null) {
+ $optionsArray = $this->options->toArray();
+ if (!empty($optionsArray)) {
+ $array[self::KEY_OPTIONS] = $optionsArray;
+ }
+ }
+ return $array;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]);
+ return new self(HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], $array[self::KEY_BODY] ?? null, isset($array[self::KEY_OPTIONS]) ? \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray($array[self::KEY_OPTIONS]) : null);
+ }
+ /**
+ * Creates a Request instance from a PSR-7 RequestInterface.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestInterface $psrRequest The PSR-7 request to convert.
+ * @return self A new Request instance.
+ * @throws InvalidArgumentException If the HTTP method is not supported.
+ */
+ public static function fromPsrRequest(RequestInterface $psrRequest): self
+ {
+ $method = HttpMethodEnum::from($psrRequest->getMethod());
+ $uri = (string) $psrRequest->getUri();
+ // Convert PSR-7 headers to array format expected by our constructor
+ /** @var array> $headers */
+ $headers = $psrRequest->getHeaders();
+ // Get body content
+ $body = $psrRequest->getBody()->getContents();
+ $bodyOrData = !empty($body) ? $body : null;
+ return new self($method, $uri, $headers, $bodyOrData);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php
new file mode 100644
index 0000000000000..c787c791df769
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php
@@ -0,0 +1,204 @@
+
+ */
+class RequestOptions extends AbstractDataTransferObject
+{
+ public const KEY_TIMEOUT = 'timeout';
+ public const KEY_CONNECT_TIMEOUT = 'connectTimeout';
+ public const KEY_MAX_REDIRECTS = 'maxRedirects';
+ /**
+ * @var float|null Maximum duration in seconds to wait for the full response.
+ */
+ protected ?float $timeout = null;
+ /**
+ * @var float|null Maximum duration in seconds to wait for the initial connection.
+ */
+ protected ?float $connectTimeout = null;
+ /**
+ * @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified.
+ */
+ protected ?int $maxRedirects = null;
+ /**
+ * Sets the request timeout in seconds.
+ *
+ * @since 0.2.0
+ *
+ * @param float|null $timeout Timeout in seconds.
+ * @return void
+ *
+ * @throws InvalidArgumentException When timeout is negative.
+ */
+ public function setTimeout(?float $timeout): void
+ {
+ $this->validateTimeout($timeout, self::KEY_TIMEOUT);
+ $this->timeout = $timeout;
+ }
+ /**
+ * Sets the connection timeout in seconds.
+ *
+ * @since 0.2.0
+ *
+ * @param float|null $timeout Connection timeout in seconds.
+ * @return void
+ *
+ * @throws InvalidArgumentException When timeout is negative.
+ */
+ public function setConnectTimeout(?float $timeout): void
+ {
+ $this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT);
+ $this->connectTimeout = $timeout;
+ }
+ /**
+ * Sets the maximum number of redirects to follow.
+ *
+ * Set to 0 to disable redirects, null for unspecified, or a positive integer
+ * to enable redirects with a maximum count.
+ *
+ * @since 0.2.0
+ *
+ * @param int|null $maxRedirects Maximum redirects to follow, or 0 to disable, or null for unspecified.
+ * @return void
+ *
+ * @throws InvalidArgumentException When redirect count is negative.
+ */
+ public function setMaxRedirects(?int $maxRedirects): void
+ {
+ if ($maxRedirects !== null && $maxRedirects < 0) {
+ throw new InvalidArgumentException('Request option "maxRedirects" must be greater than or equal to 0.');
+ }
+ $this->maxRedirects = $maxRedirects;
+ }
+ /**
+ * Gets the request timeout in seconds.
+ *
+ * @since 0.2.0
+ *
+ * @return float|null Timeout in seconds.
+ */
+ public function getTimeout(): ?float
+ {
+ return $this->timeout;
+ }
+ /**
+ * Gets the connection timeout in seconds.
+ *
+ * @since 0.2.0
+ *
+ * @return float|null Connection timeout in seconds.
+ */
+ public function getConnectTimeout(): ?float
+ {
+ return $this->connectTimeout;
+ }
+ /**
+ * Checks whether redirects are allowed.
+ *
+ * @since 0.2.0
+ *
+ * @return bool|null True when redirects are allowed (maxRedirects > 0),
+ * false when disabled (maxRedirects = 0),
+ * null when unspecified (maxRedirects = null).
+ */
+ public function allowsRedirects(): ?bool
+ {
+ if ($this->maxRedirects === null) {
+ return null;
+ }
+ return $this->maxRedirects > 0;
+ }
+ /**
+ * Gets the maximum number of redirects to follow.
+ *
+ * @since 0.2.0
+ *
+ * @return int|null Maximum redirects or null when not specified.
+ */
+ public function getMaxRedirects(): ?int
+ {
+ return $this->maxRedirects;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.2.0
+ *
+ * @return RequestOptionsArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [];
+ if ($this->timeout !== null) {
+ $data[self::KEY_TIMEOUT] = $this->timeout;
+ }
+ if ($this->connectTimeout !== null) {
+ $data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout;
+ }
+ if ($this->maxRedirects !== null) {
+ $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.2.0
+ */
+ public static function fromArray(array $array): self
+ {
+ $instance = new self();
+ if (isset($array[self::KEY_TIMEOUT])) {
+ $instance->setTimeout((float) $array[self::KEY_TIMEOUT]);
+ }
+ if (isset($array[self::KEY_CONNECT_TIMEOUT])) {
+ $instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]);
+ }
+ if (isset($array[self::KEY_MAX_REDIRECTS])) {
+ $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]);
+ }
+ return $instance;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.2.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the full response.'], self::KEY_CONNECT_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the initial connection.'], self::KEY_MAX_REDIRECTS => ['type' => ['integer', 'null'], 'minimum' => 0, 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.']], 'additionalProperties' => \false];
+ }
+ /**
+ * Validates timeout values.
+ *
+ * @since 0.2.0
+ *
+ * @param float|null $value Timeout to validate.
+ * @param string $fieldName Field name for the error message.
+ *
+ * @throws InvalidArgumentException When timeout is negative.
+ */
+ private function validateTimeout(?float $value, string $fieldName): void
+ {
+ if ($value !== null && $value < 0) {
+ throw new InvalidArgumentException(sprintf('Request option "%s" must be greater than or equal to 0.', $fieldName));
+ }
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php
new file mode 100644
index 0000000000000..73442ca456593
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php
@@ -0,0 +1,198 @@
+>,
+ * body?: string|null
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class Response extends AbstractDataTransferObject
+{
+ public const KEY_STATUS_CODE = 'statusCode';
+ public const KEY_HEADERS = 'headers';
+ public const KEY_BODY = 'body';
+ /**
+ * @var int The HTTP status code.
+ */
+ protected int $statusCode;
+ /**
+ * @var HeadersCollection The response headers.
+ */
+ protected HeadersCollection $headers;
+ /**
+ * @var string|null The response body.
+ */
+ protected ?string $body;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param int $statusCode The HTTP status code.
+ * @param array> $headers The response headers.
+ * @param string|null $body The response body.
+ *
+ * @throws InvalidArgumentException If the status code is invalid.
+ */
+ public function __construct(int $statusCode, array $headers, ?string $body = null)
+ {
+ if ($statusCode < 100 || $statusCode >= 600) {
+ throw new InvalidArgumentException('Invalid HTTP status code: ' . $statusCode);
+ }
+ $this->statusCode = $statusCode;
+ $this->headers = new HeadersCollection($headers);
+ $this->body = $body;
+ }
+ /**
+ * Gets the HTTP status code.
+ *
+ * @since 0.1.0
+ *
+ * @return int The status code.
+ */
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+ /**
+ * Gets the response headers.
+ *
+ * @since 0.1.0
+ *
+ * @return array> The headers.
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers->getAll();
+ }
+ /**
+ * Gets a specific header value.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return list|null The header value(s) or null if not found.
+ */
+ public function getHeader(string $name): ?array
+ {
+ return $this->headers->get($name);
+ }
+ /**
+ * Gets header values as a comma-separated string.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name (case-insensitive).
+ * @return string|null The header values as a comma-separated string or null if not found.
+ */
+ public function getHeaderAsString(string $name): ?string
+ {
+ return $this->headers->getAsString($name);
+ }
+ /**
+ * Gets the response body.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The body.
+ */
+ public function getBody(): ?string
+ {
+ return $this->body;
+ }
+ /**
+ * Checks if the response has a header.
+ *
+ * @since 0.1.0
+ *
+ * @param string $name The header name.
+ * @return bool True if the header exists, false otherwise.
+ */
+ public function hasHeader(string $name): bool
+ {
+ return $this->headers->has($name);
+ }
+ /**
+ * Checks if the response indicates success.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if status code is 2xx, false otherwise.
+ */
+ public function isSuccessful(): bool
+ {
+ return $this->statusCode >= 200 && $this->statusCode < 300;
+ }
+ /**
+ * Gets the response data as an array.
+ *
+ * Attempts to decode the body as JSON. Returns null if the body
+ * is empty or not valid JSON.
+ *
+ * @since 0.1.0
+ *
+ * @return array|null The decoded data or null.
+ */
+ public function getData(): ?array
+ {
+ if ($this->body === null || $this->body === '') {
+ return null;
+ }
+ $data = json_decode($this->body, \true);
+ if (json_last_error() !== \JSON_ERROR_NONE) {
+ return null;
+ }
+ /** @var array|null $data */
+ return is_array($data) ? $data : null;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_STATUS_CODE => ['type' => 'integer', 'minimum' => 100, 'maximum' => 599, 'description' => 'The HTTP status code.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The response headers.'], self::KEY_BODY => ['type' => ['string', 'null'], 'description' => 'The response body.']], 'required' => [self::KEY_STATUS_CODE, self::KEY_HEADERS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ResponseArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_STATUS_CODE => $this->statusCode, self::KEY_HEADERS => $this->headers->getAll()];
+ if ($this->body !== null) {
+ $data[self::KEY_BODY] = $this->body;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_STATUS_CODE, self::KEY_HEADERS]);
+ return new self($array[self::KEY_STATUS_CODE], $array[self::KEY_HEADERS], $array[self::KEY_BODY] ?? null);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php
new file mode 100644
index 0000000000000..42520c949cd6e
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php
@@ -0,0 +1,110 @@
+value, [self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE], \true);
+ }
+ /**
+ * Checks if this method typically has a request body.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if the method typically has a body, false otherwise.
+ */
+ public function hasBody(): bool
+ {
+ return in_array($this->value, [self::POST, self::PUT, self::PATCH], \true);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php
new file mode 100644
index 0000000000000..e43eb027579c4
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php
@@ -0,0 +1,39 @@
+ The implementation class.
+ *
+ * @phpstan-ignore missingType.generics
+ */
+ public function getImplementationClass(): string
+ {
+ // At the moment, this is the only supported method.
+ // Once more methods are available, add conditionals here for each method.
+ return ApiKeyRequestAuthentication::class;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php
new file mode 100644
index 0000000000000..569e76e066a68
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php
@@ -0,0 +1,68 @@
+request === null) {
+ throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
+ }
+ return $this->request;
+ }
+ /**
+ * Creates a ClientException from a client error response (4xx).
+ *
+ * This method extracts error details from common API response formats
+ * and creates an exception with a descriptive message and status code.
+ *
+ * @since 0.2.0
+ *
+ * @param Response $response The HTTP response that failed.
+ * @return self
+ */
+ public static function fromClientErrorResponse(Response $response): self
+ {
+ $statusCode = $response->getStatusCode();
+ $statusTexts = [400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests'];
+ if (isset($statusTexts[$statusCode])) {
+ $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
+ } else {
+ $errorMessage = sprintf('Client error (%d): Request was rejected due to client-side issue', $statusCode);
+ }
+ // Extract error message from response data using centralized utility
+ $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
+ if ($extractedError !== null) {
+ $errorMessage .= ' - ' . $extractedError;
+ }
+ return new self($errorMessage, $statusCode);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php
new file mode 100644
index 0000000000000..1b26ac2c60f0b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php
@@ -0,0 +1,57 @@
+request === null) {
+ throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
+ }
+ return $this->request;
+ }
+ /**
+ * Creates a NetworkException from a PSR-18 network exception.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestInterface $psrRequest The PSR-7 request that failed.
+ * @param \Throwable $networkException The PSR-18 network exception.
+ * @return self
+ */
+ public static function fromPsr18NetworkException(RequestInterface $psrRequest, \Throwable $networkException): self
+ {
+ $request = Request::fromPsrRequest($psrRequest);
+ $message = sprintf('Network error occurred while sending request to %s: %s', $request->getUri(), $networkException->getMessage());
+ $exception = new self($message, 0, $networkException);
+ $exception->request = $request;
+ return $exception;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php
new file mode 100644
index 0000000000000..0b21fe5219c25
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php
@@ -0,0 +1,47 @@
+getStatusCode();
+ $statusTexts = [300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 307 => 'Temporary Redirect', 308 => 'Permanent Redirect'];
+ if (isset($statusTexts[$statusCode])) {
+ $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
+ } else {
+ $errorMessage = sprintf('Redirect error (%d): Request needs to be retried at a different location', $statusCode);
+ }
+ // Try to extract the redirect location from headers
+ $locationValues = $response->getHeader('Location');
+ if ($locationValues !== null && !empty($locationValues)) {
+ $location = $locationValues[0];
+ $errorMessage .= ' - Location: ' . $location;
+ }
+ return new self($errorMessage, $statusCode);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php
new file mode 100644
index 0000000000000..3e2dd07e43014
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php
@@ -0,0 +1,46 @@
+getStatusCode();
+ $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage'];
+ if (isset($statusTexts[$statusCode])) {
+ $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
+ } else {
+ $errorMessage = sprintf('Server error (%d): Request was rejected due to server-side issue', $statusCode);
+ }
+ // Extract error message from response data using centralized utility
+ $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
+ if ($extractedError !== null) {
+ $errorMessage .= ' - ' . $extractedError;
+ }
+ return new self($errorMessage, $response->getStatusCode());
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php
new file mode 100644
index 0000000000000..dd6cc3e9e4c4b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php
@@ -0,0 +1,267 @@
+client = $client ?: Psr18ClientDiscovery::find();
+ $this->requestFactory = $requestFactory ?: Psr17FactoryDiscovery::findRequestFactory();
+ $this->streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory();
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ * @since 0.2.0 Added optional RequestOptions parameter and ClientWithOptions support.
+ */
+ public function send(Request $request, ?RequestOptions $options = null): Response
+ {
+ $psr7Request = $this->convertToPsr7Request($request);
+ // Merge request options with parameter options, with parameter options taking precedence
+ $mergedOptions = $this->mergeOptions($request->getOptions(), $options);
+ try {
+ $hasOptions = $mergedOptions !== null;
+ if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) {
+ $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions);
+ } elseif ($hasOptions && $this->isGuzzleClient($this->client)) {
+ $psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions);
+ } else {
+ $psr7Response = $this->client->sendRequest($psr7Request);
+ }
+ } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) {
+ throw NetworkException::fromPsr18NetworkException($psr7Request, $e);
+ } catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) {
+ // Handle other PSR-18 client exceptions that are not network-related
+ throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e);
+ }
+ return $this->convertFromPsr7Response($psr7Response);
+ }
+ /**
+ * Merges request options with parameter options taking precedence.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestOptions|null $requestOptions Options from the Request object.
+ * @param RequestOptions|null $parameterOptions Options passed as method parameter.
+ * @return RequestOptions|null Merged options, or null if both are null.
+ */
+ private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $parameterOptions): ?RequestOptions
+ {
+ // If no options at all, return null
+ if ($requestOptions === null && $parameterOptions === null) {
+ return null;
+ }
+ // If only one set of options exists, return it
+ if ($requestOptions === null) {
+ return $parameterOptions;
+ }
+ if ($parameterOptions === null) {
+ return $requestOptions;
+ }
+ // Both exist, merge them with parameter options taking precedence
+ $merged = new RequestOptions();
+ // Start with request options (lower precedence)
+ if ($requestOptions->getTimeout() !== null) {
+ $merged->setTimeout($requestOptions->getTimeout());
+ }
+ if ($requestOptions->getConnectTimeout() !== null) {
+ $merged->setConnectTimeout($requestOptions->getConnectTimeout());
+ }
+ if ($requestOptions->getMaxRedirects() !== null) {
+ $merged->setMaxRedirects($requestOptions->getMaxRedirects());
+ }
+ // Override with parameter options (higher precedence)
+ if ($parameterOptions->getTimeout() !== null) {
+ $merged->setTimeout($parameterOptions->getTimeout());
+ }
+ if ($parameterOptions->getConnectTimeout() !== null) {
+ $merged->setConnectTimeout($parameterOptions->getConnectTimeout());
+ }
+ if ($parameterOptions->getMaxRedirects() !== null) {
+ $merged->setMaxRedirects($parameterOptions->getMaxRedirects());
+ }
+ return $merged;
+ }
+ /**
+ * Determines if the underlying client matches the Guzzle client shape.
+ *
+ * @since 0.2.0
+ *
+ * @param ClientInterface $client The HTTP client instance.
+ * @return bool True when the client exposes Guzzle's send signature.
+ */
+ private function isGuzzleClient(ClientInterface $client): bool
+ {
+ $reflection = new \ReflectionObject($client);
+ if (!is_callable([$client, 'send'])) {
+ return \false;
+ }
+ if (!$reflection->hasMethod('send')) {
+ return \false;
+ }
+ $method = $reflection->getMethod('send');
+ if (!$method->isPublic() || $method->isStatic()) {
+ return \false;
+ }
+ $parameters = $method->getParameters();
+ if (count($parameters) < 2) {
+ return \false;
+ }
+ $firstParameter = $parameters[0]->getType();
+ if (!$firstParameter instanceof \ReflectionNamedType || $firstParameter->isBuiltin()) {
+ return \false;
+ }
+ if (!is_a($firstParameter->getName(), RequestInterface::class, \true)) {
+ return \false;
+ }
+ $secondParameter = $parameters[1];
+ $secondType = $secondParameter->getType();
+ if (!$secondType instanceof \ReflectionNamedType || $secondType->getName() !== 'array') {
+ return \false;
+ }
+ return \true;
+ }
+ /**
+ * Sends a request using a Guzzle-compatible client.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestInterface $request The PSR-7 request to send.
+ * @param RequestOptions $options The request options.
+ * @return ResponseInterface The PSR-7 response received.
+ */
+ private function sendWithGuzzle(RequestInterface $request, RequestOptions $options): ResponseInterface
+ {
+ $guzzleOptions = $this->buildGuzzleOptions($options);
+ /** @var callable $callable */
+ $callable = [$this->client, 'send'];
+ /** @var ResponseInterface $response */
+ $response = $callable($request, $guzzleOptions);
+ return $response;
+ }
+ /**
+ * Converts request options to a Guzzle-compatible options array.
+ *
+ * @since 0.2.0
+ *
+ * @param RequestOptions $options The request options.
+ * @return array Guzzle-compatible options.
+ */
+ private function buildGuzzleOptions(RequestOptions $options): array
+ {
+ $guzzleOptions = [];
+ $timeout = $options->getTimeout();
+ if ($timeout !== null) {
+ $guzzleOptions['timeout'] = $timeout;
+ }
+ $connectTimeout = $options->getConnectTimeout();
+ if ($connectTimeout !== null) {
+ $guzzleOptions['connect_timeout'] = $connectTimeout;
+ }
+ $allowRedirects = $options->allowsRedirects();
+ if ($allowRedirects !== null) {
+ if ($allowRedirects) {
+ $redirectOptions = [];
+ $maxRedirects = $options->getMaxRedirects();
+ if ($maxRedirects !== null) {
+ $redirectOptions['max'] = $maxRedirects;
+ }
+ $guzzleOptions['allow_redirects'] = !empty($redirectOptions) ? $redirectOptions : \true;
+ } else {
+ $guzzleOptions['allow_redirects'] = \false;
+ }
+ }
+ return $guzzleOptions;
+ }
+ /**
+ * Converts a custom Request to a PSR-7 request.
+ *
+ * @since 0.1.0
+ *
+ * @param Request $request The custom request.
+ * @return RequestInterface The PSR-7 request.
+ */
+ private function convertToPsr7Request(Request $request): RequestInterface
+ {
+ $psr7Request = $this->requestFactory->createRequest($request->getMethod()->value, $request->getUri());
+ // Add headers
+ foreach ($request->getHeaders() as $name => $values) {
+ foreach ($values as $value) {
+ $psr7Request = $psr7Request->withAddedHeader($name, $value);
+ }
+ }
+ // Add body if present
+ $body = $request->getBody();
+ if ($body !== null) {
+ $stream = $this->streamFactory->createStream($body);
+ $psr7Request = $psr7Request->withBody($stream);
+ }
+ return $psr7Request;
+ }
+ /**
+ * Converts a PSR-7 response to a custom Response.
+ *
+ * @since 0.1.0
+ *
+ * @param ResponseInterface $psr7Response The PSR-7 response.
+ * @return Response The custom response.
+ */
+ private function convertFromPsr7Response(ResponseInterface $psr7Response): Response
+ {
+ $body = (string) $psr7Response->getBody();
+ // PSR-7 always returns headers as arrays, but HeadersCollection handles this
+ return new Response(
+ $psr7Response->getStatusCode(),
+ $psr7Response->getHeaders(),
+ // @phpstan-ignore-line
+ $body === '' ? null : $body
+ );
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php
new file mode 100644
index 0000000000000..f2927f7e4e611
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php
@@ -0,0 +1,33 @@
+httpTransporter = $httpTransporter;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getHttpTransporter(): HttpTransporterInterface
+ {
+ if ($this->httpTransporter === null) {
+ throw new RuntimeException('HttpTransporterInterface instance not set. Make sure you use the AiClient class for all requests.');
+ }
+ return $this->httpTransporter;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php b/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php
new file mode 100644
index 0000000000000..12c13541709ea
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php
@@ -0,0 +1,40 @@
+requestAuthentication = $requestAuthentication;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getRequestAuthentication(): RequestAuthenticationInterface
+ {
+ if ($this->requestAuthentication === null) {
+ throw new RuntimeException('RequestAuthenticationInterface instance not set. ' . 'Make sure you use the AiClient class for all requests.');
+ }
+ return $this->requestAuthentication;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php b/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php
new file mode 100644
index 0000000000000..8b71f5be77be4
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php
@@ -0,0 +1,53 @@
+isSuccessful()) {
+ return;
+ }
+ $statusCode = $response->getStatusCode();
+ // 3xx Redirect Responses
+ if ($statusCode >= 300 && $statusCode < 400) {
+ throw RedirectException::fromRedirectResponse($response);
+ }
+ // 4xx Client Errors
+ if ($statusCode >= 400 && $statusCode < 500) {
+ throw ClientException::fromClientErrorResponse($response);
+ }
+ // 5xx Server Errors
+ if ($statusCode >= 500 && $statusCode < 600) {
+ throw ServerException::fromServerErrorResponse($response);
+ }
+ throw new \RuntimeException(sprintf('Response returned invalid status code: %s', $response->getStatusCode()));
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php
new file mode 100644
index 0000000000000..45abe5ab51fa7
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php
@@ -0,0 +1,52 @@
+,
+ * systemInstruction?: string,
+ * candidateCount?: int,
+ * maxTokens?: int,
+ * temperature?: float,
+ * topP?: float,
+ * topK?: int,
+ * stopSequences?: list,
+ * presencePenalty?: float,
+ * frequencyPenalty?: float,
+ * logprobs?: bool,
+ * topLogprobs?: int,
+ * functionDeclarations?: list,
+ * webSearch?: WebSearchArrayShape,
+ * outputFileType?: string,
+ * outputMimeType?: string,
+ * outputSchema?: array,
+ * outputMediaOrientation?: string,
+ * outputMediaAspectRatio?: string,
+ * outputSpeechVoice?: string,
+ * customOptions?: array
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class ModelConfig extends AbstractDataTransferObject
+{
+ public const KEY_OUTPUT_MODALITIES = 'outputModalities';
+ public const KEY_SYSTEM_INSTRUCTION = 'systemInstruction';
+ public const KEY_CANDIDATE_COUNT = 'candidateCount';
+ public const KEY_MAX_TOKENS = 'maxTokens';
+ public const KEY_TEMPERATURE = 'temperature';
+ public const KEY_TOP_P = 'topP';
+ public const KEY_TOP_K = 'topK';
+ public const KEY_STOP_SEQUENCES = 'stopSequences';
+ public const KEY_PRESENCE_PENALTY = 'presencePenalty';
+ public const KEY_FREQUENCY_PENALTY = 'frequencyPenalty';
+ public const KEY_LOGPROBS = 'logprobs';
+ public const KEY_TOP_LOGPROBS = 'topLogprobs';
+ public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations';
+ public const KEY_WEB_SEARCH = 'webSearch';
+ public const KEY_OUTPUT_FILE_TYPE = 'outputFileType';
+ public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType';
+ public const KEY_OUTPUT_SCHEMA = 'outputSchema';
+ 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_CUSTOM_OPTIONS = 'customOptions';
+ /*
+ * Note: This key is not an actual model config key, but specified here for convenience.
+ * It is relevant for model discovery, to determine which models support which input modalities.
+ * The actual input modalities are part of the message sent to the model, not the model config.
+ */
+ public const KEY_INPUT_MODALITIES = 'inputModalities';
+ /**
+ * @var list|null Output modalities for the model.
+ */
+ protected ?array $outputModalities = null;
+ /**
+ * @var string|null System instruction for the model.
+ */
+ protected ?string $systemInstruction = null;
+ /**
+ * @var int|null Number of response candidates to generate.
+ */
+ protected ?int $candidateCount = null;
+ /**
+ * @var int|null Maximum number of tokens to generate.
+ */
+ protected ?int $maxTokens = null;
+ /**
+ * @var float|null Temperature for randomness (0.0 to 2.0).
+ */
+ protected ?float $temperature = null;
+ /**
+ * @var float|null Top-p nucleus sampling parameter.
+ */
+ protected ?float $topP = null;
+ /**
+ * @var int|null Top-k sampling parameter.
+ */
+ protected ?int $topK = null;
+ /**
+ * @var list|null Stop sequences.
+ */
+ protected ?array $stopSequences = null;
+ /**
+ * @var float|null Presence penalty for reducing repetition.
+ */
+ protected ?float $presencePenalty = null;
+ /**
+ * @var float|null Frequency penalty for reducing repetition.
+ */
+ protected ?float $frequencyPenalty = null;
+ /**
+ * @var bool|null Whether to return log probabilities.
+ */
+ protected ?bool $logprobs = null;
+ /**
+ * @var int|null Number of top log probabilities to return.
+ */
+ protected ?int $topLogprobs = null;
+ /**
+ * @var list|null Function declarations available to the model.
+ */
+ protected ?array $functionDeclarations = null;
+ /**
+ * @var WebSearch|null Web search configuration for the model.
+ */
+ protected ?WebSearch $webSearch = null;
+ /**
+ * @var FileTypeEnum|null Output file type.
+ */
+ protected ?FileTypeEnum $outputFileType = null;
+ /**
+ * @var string|null Output MIME type.
+ */
+ protected ?string $outputMimeType = null;
+ /**
+ * @var array|null Output schema (JSON schema).
+ */
+ protected ?array $outputSchema = null;
+ /**
+ * @var MediaOrientationEnum|null Output media orientation.
+ */
+ protected ?MediaOrientationEnum $outputMediaOrientation = null;
+ /**
+ * @var string|null Output media aspect ratio (e.g. 3:2, 16:9).
+ */
+ protected ?string $outputMediaAspectRatio = null;
+ /**
+ * @var string|null Output speech voice.
+ */
+ protected ?string $outputSpeechVoice = null;
+ /**
+ * @var array Custom provider-specific options.
+ */
+ protected array $customOptions = [];
+ /**
+ * Sets the output modalities.
+ *
+ * @since 0.1.0
+ *
+ * @param list $outputModalities The output modalities.
+ *
+ * @throws InvalidArgumentException If the array is not a list.
+ */
+ public function setOutputModalities(array $outputModalities): void
+ {
+ if (!array_is_list($outputModalities)) {
+ throw new InvalidArgumentException('Output modalities must be a list array.');
+ }
+ $this->outputModalities = $outputModalities;
+ }
+ /**
+ * Gets the output modalities.
+ *
+ * @since 0.1.0
+ *
+ * @return list|null The output modalities.
+ */
+ public function getOutputModalities(): ?array
+ {
+ return $this->outputModalities;
+ }
+ /**
+ * Sets the system instruction.
+ *
+ * @since 0.1.0
+ *
+ * @param string $systemInstruction The system instruction.
+ */
+ public function setSystemInstruction(string $systemInstruction): void
+ {
+ $this->systemInstruction = $systemInstruction;
+ }
+ /**
+ * Gets the system instruction.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The system instruction.
+ */
+ public function getSystemInstruction(): ?string
+ {
+ return $this->systemInstruction;
+ }
+ /**
+ * Sets the candidate count.
+ *
+ * @since 0.1.0
+ *
+ * @param int $candidateCount The candidate count.
+ */
+ public function setCandidateCount(int $candidateCount): void
+ {
+ $this->candidateCount = $candidateCount;
+ }
+ /**
+ * Gets the candidate count.
+ *
+ * @since 0.1.0
+ *
+ * @return int|null The candidate count.
+ */
+ public function getCandidateCount(): ?int
+ {
+ return $this->candidateCount;
+ }
+ /**
+ * Sets the maximum tokens.
+ *
+ * @since 0.1.0
+ *
+ * @param int $maxTokens The maximum tokens.
+ */
+ public function setMaxTokens(int $maxTokens): void
+ {
+ $this->maxTokens = $maxTokens;
+ }
+ /**
+ * Gets the maximum tokens.
+ *
+ * @since 0.1.0
+ *
+ * @return int|null The maximum tokens.
+ */
+ public function getMaxTokens(): ?int
+ {
+ return $this->maxTokens;
+ }
+ /**
+ * Sets the temperature.
+ *
+ * @since 0.1.0
+ *
+ * @param float $temperature The temperature.
+ */
+ public function setTemperature(float $temperature): void
+ {
+ $this->temperature = $temperature;
+ }
+ /**
+ * Gets the temperature.
+ *
+ * @since 0.1.0
+ *
+ * @return float|null The temperature.
+ */
+ public function getTemperature(): ?float
+ {
+ return $this->temperature;
+ }
+ /**
+ * Sets the top-p parameter.
+ *
+ * @since 0.1.0
+ *
+ * @param float $topP The top-p parameter.
+ */
+ public function setTopP(float $topP): void
+ {
+ $this->topP = $topP;
+ }
+ /**
+ * Gets the top-p parameter.
+ *
+ * @since 0.1.0
+ *
+ * @return float|null The top-p parameter.
+ */
+ public function getTopP(): ?float
+ {
+ return $this->topP;
+ }
+ /**
+ * Sets the top-k parameter.
+ *
+ * @since 0.1.0
+ *
+ * @param int $topK The top-k parameter.
+ */
+ public function setTopK(int $topK): void
+ {
+ $this->topK = $topK;
+ }
+ /**
+ * Gets the top-k parameter.
+ *
+ * @since 0.1.0
+ *
+ * @return int|null The top-k parameter.
+ */
+ public function getTopK(): ?int
+ {
+ return $this->topK;
+ }
+ /**
+ * Sets the stop sequences.
+ *
+ * @since 0.1.0
+ *
+ * @param list $stopSequences The stop sequences.
+ *
+ * @throws InvalidArgumentException If the array is not a list.
+ */
+ public function setStopSequences(array $stopSequences): void
+ {
+ if (!array_is_list($stopSequences)) {
+ throw new InvalidArgumentException('Stop sequences must be a list array.');
+ }
+ $this->stopSequences = $stopSequences;
+ }
+ /**
+ * Gets the stop sequences.
+ *
+ * @since 0.1.0
+ *
+ * @return list|null The stop sequences.
+ */
+ public function getStopSequences(): ?array
+ {
+ return $this->stopSequences;
+ }
+ /**
+ * Sets the presence penalty.
+ *
+ * @since 0.1.0
+ *
+ * @param float $presencePenalty The presence penalty.
+ */
+ public function setPresencePenalty(float $presencePenalty): void
+ {
+ $this->presencePenalty = $presencePenalty;
+ }
+ /**
+ * Gets the presence penalty.
+ *
+ * @since 0.1.0
+ *
+ * @return float|null The presence penalty.
+ */
+ public function getPresencePenalty(): ?float
+ {
+ return $this->presencePenalty;
+ }
+ /**
+ * Sets the frequency penalty.
+ *
+ * @since 0.1.0
+ *
+ * @param float $frequencyPenalty The frequency penalty.
+ */
+ public function setFrequencyPenalty(float $frequencyPenalty): void
+ {
+ $this->frequencyPenalty = $frequencyPenalty;
+ }
+ /**
+ * Gets the frequency penalty.
+ *
+ * @since 0.1.0
+ *
+ * @return float|null The frequency penalty.
+ */
+ public function getFrequencyPenalty(): ?float
+ {
+ return $this->frequencyPenalty;
+ }
+ /**
+ * Sets whether to return log probabilities.
+ *
+ * @since 0.1.0
+ *
+ * @param bool $logprobs Whether to return log probabilities.
+ */
+ public function setLogprobs(bool $logprobs): void
+ {
+ $this->logprobs = $logprobs;
+ }
+ /**
+ * Gets whether to return log probabilities.
+ *
+ * @since 0.1.0
+ *
+ * @return bool|null Whether to return log probabilities.
+ */
+ public function getLogprobs(): ?bool
+ {
+ return $this->logprobs;
+ }
+ /**
+ * Sets the number of top log probabilities to return.
+ *
+ * @since 0.1.0
+ *
+ * @param int $topLogprobs The number of top log probabilities.
+ */
+ public function setTopLogprobs(int $topLogprobs): void
+ {
+ $this->topLogprobs = $topLogprobs;
+ }
+ /**
+ * Gets the number of top log probabilities to return.
+ *
+ * @since 0.1.0
+ *
+ * @return int|null The number of top log probabilities.
+ */
+ public function getTopLogprobs(): ?int
+ {
+ return $this->topLogprobs;
+ }
+ /**
+ * Sets the function declarations.
+ *
+ * @since 0.1.0
+ *
+ * @param list $function_declarations The function declarations.
+ *
+ * @throws InvalidArgumentException If the array is not a list.
+ */
+ public function setFunctionDeclarations(array $function_declarations): void
+ {
+ if (!array_is_list($function_declarations)) {
+ throw new InvalidArgumentException('Function declarations must be a list array.');
+ }
+ $this->functionDeclarations = $function_declarations;
+ }
+ /**
+ * Gets the function declarations.
+ *
+ * @since 0.1.0
+ *
+ * @return list|null The function declarations.
+ */
+ public function getFunctionDeclarations(): ?array
+ {
+ return $this->functionDeclarations;
+ }
+ /**
+ * Sets the web search configuration.
+ *
+ * @since 0.1.0
+ *
+ * @param WebSearch $web_search The web search configuration.
+ */
+ public function setWebSearch(WebSearch $web_search): void
+ {
+ $this->webSearch = $web_search;
+ }
+ /**
+ * Gets the web search configuration.
+ *
+ * @since 0.1.0
+ *
+ * @return WebSearch|null The web search configuration.
+ */
+ public function getWebSearch(): ?WebSearch
+ {
+ return $this->webSearch;
+ }
+ /**
+ * Sets the output file type.
+ *
+ * @since 0.1.0
+ *
+ * @param FileTypeEnum $outputFileType The output file type.
+ */
+ public function setOutputFileType(FileTypeEnum $outputFileType): void
+ {
+ $this->outputFileType = $outputFileType;
+ }
+ /**
+ * Gets the output file type.
+ *
+ * @since 0.1.0
+ *
+ * @return FileTypeEnum|null The output file type.
+ */
+ public function getOutputFileType(): ?FileTypeEnum
+ {
+ return $this->outputFileType;
+ }
+ /**
+ * Sets the output MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @param string $outputMimeType The output MIME type.
+ */
+ public function setOutputMimeType(string $outputMimeType): void
+ {
+ $this->outputMimeType = $outputMimeType;
+ }
+ /**
+ * Gets the output MIME type.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The output MIME type.
+ */
+ public function getOutputMimeType(): ?string
+ {
+ return $this->outputMimeType;
+ }
+ /**
+ * Sets the output schema.
+ *
+ * When setting an output schema, this method automatically sets
+ * the output MIME type to "application/json" if not already set.
+ *
+ * @since 0.1.0
+ *
+ * @param array $outputSchema The output schema (JSON schema).
+ */
+ public function setOutputSchema(array $outputSchema): void
+ {
+ $this->outputSchema = $outputSchema;
+ // Automatically set outputMimeType to application/json when schema is provided
+ if ($this->outputMimeType === null) {
+ $this->outputMimeType = 'application/json';
+ }
+ }
+ /**
+ * Gets the output schema.
+ *
+ * @since 0.1.0
+ *
+ * @return array|null The output schema.
+ */
+ public function getOutputSchema(): ?array
+ {
+ return $this->outputSchema;
+ }
+ /**
+ * Sets the output media orientation.
+ *
+ * @since 0.1.0
+ *
+ * @param MediaOrientationEnum $outputMediaOrientation The output media orientation.
+ */
+ public function setOutputMediaOrientation(MediaOrientationEnum $outputMediaOrientation): void
+ {
+ if ($this->outputMediaAspectRatio) {
+ $this->validateMediaOrientationAspectRatioCompatibility($outputMediaOrientation, $this->outputMediaAspectRatio);
+ }
+ $this->outputMediaOrientation = $outputMediaOrientation;
+ }
+ /**
+ * Gets the output media orientation.
+ *
+ * @since 0.1.0
+ *
+ * @return MediaOrientationEnum|null The output media orientation.
+ */
+ public function getOutputMediaOrientation(): ?MediaOrientationEnum
+ {
+ return $this->outputMediaOrientation;
+ }
+ /**
+ * Sets the output media aspect ratio.
+ *
+ * If set, this supersedes the output media orientation, as it is a more specific configuration.
+ *
+ * @since 0.1.0
+ *
+ * @param string $outputMediaAspectRatio The output media aspect ratio (e.g. 3:2, 16:9).
+ */
+ public function setOutputMediaAspectRatio(string $outputMediaAspectRatio): void
+ {
+ if (!preg_match('/^\d+:\d+$/', $outputMediaAspectRatio)) {
+ throw new InvalidArgumentException('Output media aspect ratio must be in the format "width:height" (e.g. 3:2, 16:9).');
+ }
+ if ($this->outputMediaOrientation) {
+ $this->validateMediaOrientationAspectRatioCompatibility($this->outputMediaOrientation, $outputMediaAspectRatio);
+ }
+ $this->outputMediaAspectRatio = $outputMediaAspectRatio;
+ }
+ /**
+ * Gets the output media aspect ratio.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The output media aspect ratio (e.g. 3:2, 16:9).
+ */
+ public function getOutputMediaAspectRatio(): ?string
+ {
+ return $this->outputMediaAspectRatio;
+ }
+ /**
+ * Validates that the given media orientation and aspect ratio values do not conflict with each other.
+ *
+ * @since 0.4.0
+ *
+ * @param MediaOrientationEnum $orientation The desired media orientation.
+ * @param string $aspectRatio The desired media aspect ratio.
+ */
+ protected function validateMediaOrientationAspectRatioCompatibility(MediaOrientationEnum $orientation, string $aspectRatio): void
+ {
+ if ($orientation->isSquare() && $aspectRatio !== '1:1') {
+ throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the square orientation.');
+ }
+ $aspectRatioParts = explode(':', $aspectRatio);
+ if ($orientation->isLandscape() && $aspectRatioParts[0] <= $aspectRatioParts[1]) {
+ throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the landscape orientation.');
+ }
+ if ($orientation->isPortrait() && $aspectRatioParts[0] >= $aspectRatioParts[1]) {
+ throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the portrait orientation.');
+ }
+ }
+ /**
+ * Sets the output speech voice.
+ *
+ * @since 0.1.0
+ *
+ * @param string $outputSpeechVoice The output speech voice.
+ */
+ public function setOutputSpeechVoice(string $outputSpeechVoice): void
+ {
+ $this->outputSpeechVoice = $outputSpeechVoice;
+ }
+ /**
+ * Gets the output speech voice.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The output speech voice.
+ */
+ public function getOutputSpeechVoice(): ?string
+ {
+ return $this->outputSpeechVoice;
+ }
+ /**
+ * Sets a single custom option.
+ *
+ * @since 0.1.0
+ *
+ * @param string $key The option key.
+ * @param mixed $value The option value.
+ */
+ public function setCustomOption(string $key, $value): void
+ {
+ $this->customOptions[$key] = $value;
+ }
+ /**
+ * Sets the custom options.
+ *
+ * @since 0.1.0
+ *
+ * @param array $customOptions The custom options.
+ */
+ public function setCustomOptions(array $customOptions): void
+ {
+ $this->customOptions = $customOptions;
+ }
+ /**
+ * Gets the custom options.
+ *
+ * @since 0.1.0
+ *
+ * @return array The custom options.
+ */
+ public function getCustomOptions(): array
+ {
+ return $this->customOptions;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ 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];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ModelConfigArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [];
+ if ($this->outputModalities !== null) {
+ $data[self::KEY_OUTPUT_MODALITIES] = array_map(static function (ModalityEnum $modality): string {
+ return $modality->value;
+ }, $this->outputModalities);
+ }
+ if ($this->systemInstruction !== null) {
+ $data[self::KEY_SYSTEM_INSTRUCTION] = $this->systemInstruction;
+ }
+ if ($this->candidateCount !== null) {
+ $data[self::KEY_CANDIDATE_COUNT] = $this->candidateCount;
+ }
+ if ($this->maxTokens !== null) {
+ $data[self::KEY_MAX_TOKENS] = $this->maxTokens;
+ }
+ if ($this->temperature !== null) {
+ $data[self::KEY_TEMPERATURE] = $this->temperature;
+ }
+ if ($this->topP !== null) {
+ $data[self::KEY_TOP_P] = $this->topP;
+ }
+ if ($this->topK !== null) {
+ $data[self::KEY_TOP_K] = $this->topK;
+ }
+ if ($this->stopSequences !== null) {
+ $data[self::KEY_STOP_SEQUENCES] = $this->stopSequences;
+ }
+ if ($this->presencePenalty !== null) {
+ $data[self::KEY_PRESENCE_PENALTY] = $this->presencePenalty;
+ }
+ if ($this->frequencyPenalty !== null) {
+ $data[self::KEY_FREQUENCY_PENALTY] = $this->frequencyPenalty;
+ }
+ if ($this->logprobs !== null) {
+ $data[self::KEY_LOGPROBS] = $this->logprobs;
+ }
+ if ($this->topLogprobs !== null) {
+ $data[self::KEY_TOP_LOGPROBS] = $this->topLogprobs;
+ }
+ if ($this->functionDeclarations !== null) {
+ $data[self::KEY_FUNCTION_DECLARATIONS] = array_map(static function (FunctionDeclaration $function_declaration): array {
+ return $function_declaration->toArray();
+ }, $this->functionDeclarations);
+ }
+ if ($this->webSearch !== null) {
+ $data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray();
+ }
+ if ($this->outputFileType !== null) {
+ $data[self::KEY_OUTPUT_FILE_TYPE] = $this->outputFileType->value;
+ }
+ if ($this->outputMimeType !== null) {
+ $data[self::KEY_OUTPUT_MIME_TYPE] = $this->outputMimeType;
+ }
+ if ($this->outputSchema !== null) {
+ $data[self::KEY_OUTPUT_SCHEMA] = $this->outputSchema;
+ }
+ if ($this->outputMediaOrientation !== null) {
+ $data[self::KEY_OUTPUT_MEDIA_ORIENTATION] = $this->outputMediaOrientation->value;
+ }
+ if ($this->outputMediaAspectRatio !== null) {
+ $data[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO] = $this->outputMediaAspectRatio;
+ }
+ if ($this->outputSpeechVoice !== null) {
+ $data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice;
+ }
+ if (!empty($this->customOptions)) {
+ $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ $config = new self();
+ if (isset($array[self::KEY_OUTPUT_MODALITIES])) {
+ $config->setOutputModalities(array_map(static fn(string $modality): ModalityEnum => ModalityEnum::from($modality), $array[self::KEY_OUTPUT_MODALITIES]));
+ }
+ if (isset($array[self::KEY_SYSTEM_INSTRUCTION])) {
+ $config->setSystemInstruction($array[self::KEY_SYSTEM_INSTRUCTION]);
+ }
+ if (isset($array[self::KEY_CANDIDATE_COUNT])) {
+ $config->setCandidateCount($array[self::KEY_CANDIDATE_COUNT]);
+ }
+ if (isset($array[self::KEY_MAX_TOKENS])) {
+ $config->setMaxTokens($array[self::KEY_MAX_TOKENS]);
+ }
+ if (isset($array[self::KEY_TEMPERATURE])) {
+ $config->setTemperature($array[self::KEY_TEMPERATURE]);
+ }
+ if (isset($array[self::KEY_TOP_P])) {
+ $config->setTopP($array[self::KEY_TOP_P]);
+ }
+ if (isset($array[self::KEY_TOP_K])) {
+ $config->setTopK($array[self::KEY_TOP_K]);
+ }
+ if (isset($array[self::KEY_STOP_SEQUENCES])) {
+ $config->setStopSequences($array[self::KEY_STOP_SEQUENCES]);
+ }
+ if (isset($array[self::KEY_PRESENCE_PENALTY])) {
+ $config->setPresencePenalty($array[self::KEY_PRESENCE_PENALTY]);
+ }
+ if (isset($array[self::KEY_FREQUENCY_PENALTY])) {
+ $config->setFrequencyPenalty($array[self::KEY_FREQUENCY_PENALTY]);
+ }
+ if (isset($array[self::KEY_LOGPROBS])) {
+ $config->setLogprobs($array[self::KEY_LOGPROBS]);
+ }
+ if (isset($array[self::KEY_TOP_LOGPROBS])) {
+ $config->setTopLogprobs($array[self::KEY_TOP_LOGPROBS]);
+ }
+ if (isset($array[self::KEY_FUNCTION_DECLARATIONS])) {
+ $config->setFunctionDeclarations(array_map(static function (array $function_declaration_data): FunctionDeclaration {
+ return FunctionDeclaration::fromArray($function_declaration_data);
+ }, $array[self::KEY_FUNCTION_DECLARATIONS]));
+ }
+ if (isset($array[self::KEY_WEB_SEARCH])) {
+ $config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH]));
+ }
+ if (isset($array[self::KEY_OUTPUT_FILE_TYPE])) {
+ $config->setOutputFileType(FileTypeEnum::from($array[self::KEY_OUTPUT_FILE_TYPE]));
+ }
+ if (isset($array[self::KEY_OUTPUT_MIME_TYPE])) {
+ $config->setOutputMimeType($array[self::KEY_OUTPUT_MIME_TYPE]);
+ }
+ if (isset($array[self::KEY_OUTPUT_SCHEMA])) {
+ $config->setOutputSchema($array[self::KEY_OUTPUT_SCHEMA]);
+ }
+ if (isset($array[self::KEY_OUTPUT_MEDIA_ORIENTATION])) {
+ $config->setOutputMediaOrientation(MediaOrientationEnum::from($array[self::KEY_OUTPUT_MEDIA_ORIENTATION]));
+ }
+ if (isset($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO])) {
+ $config->setOutputMediaAspectRatio($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO]);
+ }
+ if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) {
+ $config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]);
+ }
+ if (isset($array[self::KEY_CUSTOM_OPTIONS])) {
+ $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]);
+ }
+ return $config;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php
new file mode 100644
index 0000000000000..ee2775a018f12
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php
@@ -0,0 +1,165 @@
+,
+ * supportedOptions: list
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class ModelMetadata extends AbstractDataTransferObject
+{
+ public const KEY_ID = 'id';
+ public const KEY_NAME = 'name';
+ public const KEY_SUPPORTED_CAPABILITIES = 'supportedCapabilities';
+ public const KEY_SUPPORTED_OPTIONS = 'supportedOptions';
+ /**
+ * @var string The model's unique identifier.
+ */
+ protected string $id;
+ /**
+ * @var string The model's display name.
+ */
+ protected string $name;
+ /**
+ * @var list The model's supported capabilities.
+ */
+ protected array $supportedCapabilities;
+ /**
+ * @var list The model's supported configuration options.
+ */
+ protected array $supportedOptions;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $id The model's unique identifier.
+ * @param string $name The model's display name.
+ * @param list $supportedCapabilities The model's supported capabilities.
+ * @param list $supportedOptions The model's supported configuration options.
+ *
+ * @throws InvalidArgumentException If arrays are not lists.
+ */
+ public function __construct(string $id, string $name, array $supportedCapabilities, array $supportedOptions)
+ {
+ if (!array_is_list($supportedCapabilities)) {
+ throw new InvalidArgumentException('Supported capabilities must be a list array.');
+ }
+ if (!array_is_list($supportedOptions)) {
+ throw new InvalidArgumentException('Supported options must be a list array.');
+ }
+ $this->id = $id;
+ $this->name = $name;
+ $this->supportedCapabilities = $supportedCapabilities;
+ $this->supportedOptions = $supportedOptions;
+ }
+ /**
+ * Gets the model's unique identifier.
+ *
+ * @since 0.1.0
+ *
+ * @return string The model ID.
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+ /**
+ * Gets the model's display name.
+ *
+ * @since 0.1.0
+ *
+ * @return string The model name.
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the model's supported capabilities.
+ *
+ * @since 0.1.0
+ *
+ * @return list The supported capabilities.
+ */
+ public function getSupportedCapabilities(): array
+ {
+ return $this->supportedCapabilities;
+ }
+ /**
+ * Gets the model's supported configuration options.
+ *
+ * @since 0.1.0
+ *
+ * @return list The supported options.
+ */
+ public function getSupportedOptions(): array
+ {
+ return $this->supportedOptions;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The model\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The model\'s display name.'], self::KEY_SUPPORTED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The model\'s supported capabilities.'], self::KEY_SUPPORTED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::getJsonSchema(), 'description' => 'The model\'s supported configuration options.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ModelMetadataArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_SUPPORTED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->supportedCapabilities), self::KEY_SUPPORTED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\SupportedOption $option): array => $option->toArray(), $this->supportedOptions)];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]);
+ return new self($array[self::KEY_ID], $array[self::KEY_NAME], array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_SUPPORTED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\SupportedOption => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::fromArray($optionData), $array[self::KEY_SUPPORTED_OPTIONS]));
+ }
+ /**
+ * Performs a deep clone of the model metadata.
+ *
+ * This method ensures that supported option objects are cloned to prevent
+ * modifications to the cloned metadata from affecting the original.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $clonedOptions = [];
+ foreach ($this->supportedOptions as $option) {
+ $clonedOptions[] = clone $option;
+ }
+ $this->supportedOptions = $clonedOptions;
+ }
+}
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
new file mode 100644
index 0000000000000..0f2bb865ca55a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php
@@ -0,0 +1,315 @@
+,
+ * requiredOptions: list
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class ModelRequirements extends AbstractDataTransferObject
+{
+ public const KEY_REQUIRED_CAPABILITIES = 'requiredCapabilities';
+ public const KEY_REQUIRED_OPTIONS = 'requiredOptions';
+ /**
+ * @var list The capabilities that the model must support.
+ */
+ protected array $requiredCapabilities;
+ /**
+ * @var list The options that the model must support with specific values.
+ */
+ protected array $requiredOptions;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param list $requiredCapabilities The capabilities that the model must support.
+ * @param list $requiredOptions The options that the model must support with specific values.
+ *
+ * @throws InvalidArgumentException If arrays are not lists.
+ */
+ public function __construct(array $requiredCapabilities, array $requiredOptions)
+ {
+ if (!array_is_list($requiredCapabilities)) {
+ throw new InvalidArgumentException('Required capabilities must be a list array.');
+ }
+ if (!array_is_list($requiredOptions)) {
+ throw new InvalidArgumentException('Required options must be a list array.');
+ }
+ $this->requiredCapabilities = $requiredCapabilities;
+ $this->requiredOptions = $requiredOptions;
+ }
+ /**
+ * Gets the capabilities that the model must support.
+ *
+ * @since 0.1.0
+ *
+ * @return list The required capabilities.
+ */
+ public function getRequiredCapabilities(): array
+ {
+ return $this->requiredCapabilities;
+ }
+ /**
+ * Gets the options that the model must support with specific values.
+ *
+ * @since 0.1.0
+ *
+ * @return list The required options.
+ */
+ public function getRequiredOptions(): array
+ {
+ return $this->requiredOptions;
+ }
+ /**
+ * Checks whether the given model metadata meets these requirements.
+ *
+ * @since 0.2.0
+ *
+ * @param ModelMetadata $metadata The model metadata to check against.
+ * @return bool True if the model meets all requirements, false otherwise.
+ */
+ public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): bool
+ {
+ // Create lookup maps for better performance (instead of nested foreach loops)
+ $capabilitiesMap = [];
+ foreach ($metadata->getSupportedCapabilities() as $capability) {
+ $capabilitiesMap[$capability->value] = $capability;
+ }
+ $optionsMap = [];
+ foreach ($metadata->getSupportedOptions() as $option) {
+ $optionsMap[$option->getName()->value] = $option;
+ }
+ // Check if all required capabilities are supported using map lookup
+ foreach ($this->requiredCapabilities as $requiredCapability) {
+ if (!isset($capabilitiesMap[$requiredCapability->value])) {
+ return \false;
+ }
+ }
+ // Check if all required options are supported with the specified values
+ 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;
+ }
+ }
+ return \true;
+ }
+ /**
+ * Creates ModelRequirements from prompt data and model configuration.
+ *
+ * @since 0.2.0
+ *
+ * @param CapabilityEnum $capability The capability the model must support.
+ * @param list $messages The messages in the conversation.
+ * @param ModelConfig $modelConfig The model configuration.
+ * @return self The created requirements.
+ */
+ public static function fromPromptData(CapabilityEnum $capability, array $messages, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self
+ {
+ // Start with base capability
+ $capabilities = [$capability];
+ $inputModalities = [];
+ // Check if we have chat history (multiple messages)
+ if (count($messages) > 1) {
+ $capabilities[] = CapabilityEnum::chatHistory();
+ }
+ // Analyze all messages to determine required input modalities
+ $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();
+ }
+ }
+ }
+ // Check for function calls/responses (these might require special capabilities)
+ if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) {
+ $hasFunctionMessageParts = \true;
+ }
+ }
+ }
+ // Convert ModelConfig to RequiredOptions
+ $requiredOptions = self::toRequiredOptions($modelConfig);
+ // Add additional options based on message analysis
+ if ($hasFunctionMessageParts) {
+ $requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true));
+ }
+ // 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)));
+ }
+ // Step 6: Return new ModelRequirements
+ return new self($capabilities, $requiredOptions);
+ }
+ /**
+ * Converts ModelConfig to an array of RequiredOptions.
+ *
+ * @since 0.2.0
+ *
+ * @param ModelConfig $modelConfig The model configuration.
+ * @return list The required options.
+ */
+ private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): array
+ {
+ $requiredOptions = [];
+ // Map properties that have corresponding OptionEnum values
+ if ($modelConfig->getOutputModalities() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputModalities(), $modelConfig->getOutputModalities());
+ }
+ if ($modelConfig->getSystemInstruction() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::systemInstruction(), $modelConfig->getSystemInstruction());
+ }
+ if ($modelConfig->getCandidateCount() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::candidateCount(), $modelConfig->getCandidateCount());
+ }
+ if ($modelConfig->getMaxTokens() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::maxTokens(), $modelConfig->getMaxTokens());
+ }
+ if ($modelConfig->getTemperature() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::temperature(), $modelConfig->getTemperature());
+ }
+ if ($modelConfig->getTopP() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topP(), $modelConfig->getTopP());
+ }
+ if ($modelConfig->getTopK() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topK(), $modelConfig->getTopK());
+ }
+ if ($modelConfig->getOutputMimeType() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMimeType(), $modelConfig->getOutputMimeType());
+ }
+ if ($modelConfig->getOutputSchema() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputSchema(), $modelConfig->getOutputSchema());
+ }
+ // Handle properties without OptionEnum values as custom options
+ if ($modelConfig->getStopSequences() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::stopSequences(), $modelConfig->getStopSequences());
+ }
+ if ($modelConfig->getPresencePenalty() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::presencePenalty(), $modelConfig->getPresencePenalty());
+ }
+ if ($modelConfig->getFrequencyPenalty() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::frequencyPenalty(), $modelConfig->getFrequencyPenalty());
+ }
+ if ($modelConfig->getLogprobs() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::logprobs(), $modelConfig->getLogprobs());
+ }
+ if ($modelConfig->getTopLogprobs() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topLogprobs(), $modelConfig->getTopLogprobs());
+ }
+ if ($modelConfig->getFunctionDeclarations() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true);
+ }
+ if ($modelConfig->getWebSearch() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::webSearch(), \true);
+ }
+ if ($modelConfig->getOutputFileType() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputFileType(), $modelConfig->getOutputFileType());
+ }
+ if ($modelConfig->getOutputMediaOrientation() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaOrientation(), $modelConfig->getOutputMediaOrientation());
+ }
+ if ($modelConfig->getOutputMediaAspectRatio() !== null) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio());
+ }
+ // Add custom options as individual RequiredOptions
+ foreach ($modelConfig->getCustomOptions() as $key => $value) {
+ $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]);
+ }
+ return $requiredOptions;
+ }
+ /**
+ * Includes a RequiredOption in the array, ensuring no duplicates based on option name.
+ *
+ * @since 0.2.0
+ *
+ * @param list $requiredOptions The existing required options.
+ * @param RequiredOption $newOption The new option to include.
+ * @return list The updated required options array.
+ */
+ private static function includeInRequiredOptions(array $requiredOptions, \WordPress\AiClient\Providers\Models\DTO\RequiredOption $newOption): array
+ {
+ // Check if we already have this option name
+ foreach ($requiredOptions as $index => $existingOption) {
+ if ($existingOption->getName()->equals($newOption->getName())) {
+ // Replace existing option with new one
+ $requiredOptions[$index] = $newOption;
+ return $requiredOptions;
+ }
+ }
+ // Option not found, add it
+ $requiredOptions[] = $newOption;
+ return $requiredOptions;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_REQUIRED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The capabilities that the model must support.'], self::KEY_REQUIRED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::getJsonSchema(), 'description' => 'The options that the model must support with specific values.']], 'required' => [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return ModelRequirementsArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_REQUIRED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->requiredCapabilities), self::KEY_REQUIRED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\RequiredOption $option): array => $option->toArray(), $this->requiredOptions)];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]);
+ return new self(array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_REQUIRED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\RequiredOption => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::fromArray($optionData), $array[self::KEY_REQUIRED_OPTIONS]));
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php
new file mode 100644
index 0000000000000..e459a74e9cfb3
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php
@@ -0,0 +1,100 @@
+
+ */
+class RequiredOption extends AbstractDataTransferObject
+{
+ public const KEY_NAME = 'name';
+ public const KEY_VALUE = 'value';
+ /**
+ * @var OptionEnum The option name.
+ */
+ protected OptionEnum $name;
+ /**
+ * @var mixed The value that the model must support for this option.
+ */
+ protected $value;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param OptionEnum $name The option name.
+ * @param mixed $value The value that the model must support for this option.
+ */
+ public function __construct(OptionEnum $name, $value)
+ {
+ $this->name = $name;
+ $this->value = $value;
+ }
+ /**
+ * Gets the option name.
+ *
+ * @since 0.1.0
+ *
+ * @return OptionEnum The option name.
+ */
+ public function getName(): OptionEnum
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the value that the model must support for this option.
+ *
+ * @since 0.1.0
+ *
+ * @return mixed The value that the model must support.
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_VALUE => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']], 'description' => 'The value that the model must support for this option.']], 'required' => [self::KEY_NAME, self::KEY_VALUE]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return RequiredOptionArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_NAME => $this->name->value, self::KEY_VALUE => $this->value];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_VALUE]);
+ return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_VALUE]);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php
new file mode 100644
index 0000000000000..9fd337eb6152a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php
@@ -0,0 +1,142 @@
+
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class SupportedOption extends AbstractDataTransferObject
+{
+ public const KEY_NAME = 'name';
+ public const KEY_SUPPORTED_VALUES = 'supportedValues';
+ /**
+ * @var OptionEnum The option name.
+ */
+ protected OptionEnum $name;
+ /**
+ * @var list|null The supported values for this option.
+ */
+ protected ?array $supportedValues;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param OptionEnum $name The option name.
+ * @param list|null $supportedValues The supported values for this option, or null if any value is supported.
+ *
+ * @throws InvalidArgumentException If supportedValues is not null and not a list.
+ */
+ public function __construct(OptionEnum $name, ?array $supportedValues = null)
+ {
+ if ($supportedValues !== null && !array_is_list($supportedValues)) {
+ throw new InvalidArgumentException('Supported values must be a list array.');
+ }
+ $this->name = $name;
+ $this->supportedValues = $supportedValues;
+ }
+ /**
+ * Gets the option name.
+ *
+ * @since 0.1.0
+ *
+ * @return OptionEnum The option name.
+ */
+ public function getName(): OptionEnum
+ {
+ return $this->name;
+ }
+ /**
+ * Checks if a value is supported for this option.
+ *
+ * @since 0.1.0
+ *
+ * @param mixed $value The value to check.
+ * @return bool True if the value is supported, false otherwise.
+ */
+ public function isSupportedValue($value): bool
+ {
+ // If supportedValues is null, any value is supported
+ if ($this->supportedValues === null) {
+ return \true;
+ }
+ // If the value is an array, consider it a set (i.e. order doesn't matter).
+ if (is_array($value)) {
+ sort($value);
+ foreach ($this->supportedValues as $supportedValue) {
+ if (!is_array($supportedValue)) {
+ continue;
+ }
+ sort($supportedValue);
+ if ($value === $supportedValue) {
+ return \true;
+ }
+ }
+ return \false;
+ }
+ return in_array($value, $this->supportedValues, \true);
+ }
+ /**
+ * Gets the supported values for this option.
+ *
+ * @since 0.1.0
+ *
+ * @return list|null The supported values, or null if any value is supported.
+ */
+ public function getSupportedValues(): ?array
+ {
+ return $this->supportedValues;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_SUPPORTED_VALUES => ['type' => 'array', 'items' => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']]], 'description' => 'The supported values for this option.']], 'required' => [self::KEY_NAME]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return SupportedOptionArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_NAME => $this->name->value];
+ if ($this->supportedValues !== null) {
+ /** @var list $supportedValues */
+ $supportedValues = $this->supportedValues;
+ $data[self::KEY_SUPPORTED_VALUES] = $supportedValues;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_NAME]);
+ return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_SUPPORTED_VALUES] ?? null);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php
new file mode 100644
index 0000000000000..b0bcf5abec89a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php
@@ -0,0 +1,63 @@
+ The enum constants.
+ */
+ protected static function determineClassEnumerations(string $className): array
+ {
+ // Start with the constants defined in this class using parent method
+ $constants = parent::determineClassEnumerations($className);
+ // Use reflection to get all constants from ModelConfig
+ $modelConfigReflection = new ReflectionClass(ModelConfig::class);
+ $modelConfigConstants = $modelConfigReflection->getConstants();
+ // Add ModelConfig constants that start with KEY_
+ foreach ($modelConfigConstants as $constantName => $constantValue) {
+ if (str_starts_with($constantName, 'KEY_')) {
+ // Remove KEY_ prefix to get the enum constant name
+ $enumConstantName = substr($constantName, 4);
+ // The value is the snake_case version stored in ModelConfig
+ // ModelConfig already stores these as snake_case strings
+ if (is_string($constantValue)) {
+ $constants[$enumConstantName] = $constantValue;
+ }
+ }
+ }
+ return $constants;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php
new file mode 100644
index 0000000000000..34fb5ad91f6b8
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the image generation prompt.
+ * @return GenerativeAiResult Result containing generated images.
+ */
+ public function generateImageResult(array $prompt): GenerativeAiResult;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php
new file mode 100644
index 0000000000000..52470600117a3
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the image generation prompt.
+ * @return GenerativeAiOperation The initiated image generation operation.
+ */
+ public function generateImageOperation(array $prompt): GenerativeAiOperation;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php
new file mode 100644
index 0000000000000..6fbf222f90e0c
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the speech generation prompt.
+ * @return GenerativeAiResult Result containing generated speech audio.
+ */
+ public function generateSpeechResult(array $prompt): GenerativeAiResult;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php
new file mode 100644
index 0000000000000..55305e7a6e6d3
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the speech generation prompt.
+ * @return GenerativeAiOperation The initiated speech generation operation.
+ */
+ public function generateSpeechOperation(array $prompt): GenerativeAiOperation;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php
new file mode 100644
index 0000000000000..b455206e86f1a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the text generation prompt.
+ * @return GenerativeAiResult Result containing generated text.
+ */
+ public function generateTextResult(array $prompt): GenerativeAiResult;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php
new file mode 100644
index 0000000000000..a4ae0de91863f
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the text generation prompt.
+ * @return GenerativeAiOperation The initiated text generation operation.
+ */
+ public function generateTextOperation(array $prompt): GenerativeAiOperation;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php
new file mode 100644
index 0000000000000..e97c580803642
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the text to convert to speech.
+ * @return GenerativeAiResult Result containing generated speech audio.
+ */
+ public function convertTextToSpeechResult(array $prompt): GenerativeAiResult;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php
new file mode 100644
index 0000000000000..e048bf2a780aa
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php
@@ -0,0 +1,26 @@
+ $prompt Array of messages containing the text to convert to speech.
+ * @return GenerativeAiOperation The initiated text-to-speech conversion operation.
+ */
+ public function convertTextToSpeechOperation(array $prompt): GenerativeAiOperation;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
new file mode 100644
index 0000000000000..c0747093efadb
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
@@ -0,0 +1,298 @@
+,
+ * usage?: UsageData
+ * }
+ */
+abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface
+{
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function generateImageResult(array $prompt): GenerativeAiResult
+ {
+ $httpTransporter = $this->getHttpTransporter();
+ $params = $this->prepareGenerateImageParams($prompt);
+ $request = $this->createRequest(HttpMethodEnum::POST(), 'images/generations', ['Content-Type' => 'application/json'], $params);
+ // Add authentication credentials to the request.
+ $request = $this->getRequestAuthentication()->authenticateRequest($request);
+ // Send and process the request.
+ $response = $httpTransporter->send($request);
+ $this->throwIfNotSuccessful($response);
+ return $this->parseResponseToGenerativeAiResult($response, isset($params['output_format']) && is_string($params['output_format']) ? "image/{$params['output_format']}" : 'image/png');
+ }
+ /**
+ * Prepares the given prompt and the model configuration into parameters for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param list $prompt The prompt to generate an image for. Either a single message or a list of messages
+ * from a chat. However as of today, OpenAI compatible image generation endpoints only
+ * support a single user message.
+ * @return ImageGenerationParams The parameters for the API request.
+ */
+ protected function prepareGenerateImageParams(array $prompt): array
+ {
+ $config = $this->getConfig();
+ $params = ['model' => $this->metadata()->getId(), 'prompt' => $this->preparePromptParam($prompt)];
+ $candidateCount = $config->getCandidateCount();
+ if ($candidateCount !== null) {
+ $params['n'] = $candidateCount;
+ }
+ $outputFileType = $config->getOutputFileType();
+ if ($outputFileType !== null) {
+ $params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json';
+ } else {
+ // The 'response_format' parameter is required, so we default to 'b64_json' if not set.
+ $params['response_format'] = 'b64_json';
+ }
+ $outputMimeType = $config->getOutputMimeType();
+ if ($outputMimeType !== null) {
+ $params['output_format'] = preg_replace('/^image\//', '', $outputMimeType);
+ }
+ $outputMediaOrientation = $config->getOutputMediaOrientation();
+ $outputMediaAspectRatio = $config->getOutputMediaAspectRatio();
+ if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) {
+ $params['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio);
+ }
+ /*
+ * Any custom options are added to the parameters as well.
+ * This allows developers to pass other options that may be more niche or not yet supported by the SDK.
+ */
+ $customOptions = $config->getCustomOptions();
+ foreach ($customOptions as $key => $value) {
+ if (isset($params[$key])) {
+ throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
+ }
+ $params[$key] = $value;
+ }
+ /** @var ImageGenerationParams $params */
+ return $params;
+ }
+ /**
+ * Prepares the prompt parameter for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param list $messages The messages to prepare. However as of today, OpenAI compatible image generation
+ * endpoints only support a single user message.
+ * @return string The prepared prompt parameter.
+ */
+ protected function preparePromptParam(array $messages): string
+ {
+ if (count($messages) !== 1) {
+ throw new InvalidArgumentException('The API requires a single user message as prompt.');
+ }
+ $message = $messages[0];
+ if (!$message->getRole()->isUser()) {
+ throw new InvalidArgumentException('The API requires a user message as prompt.');
+ }
+ $text = null;
+ foreach ($message->getParts() as $part) {
+ $text = $part->getText();
+ if ($text !== null) {
+ break;
+ }
+ }
+ if ($text === null) {
+ throw new InvalidArgumentException('The API requires a single text message part as prompt.');
+ }
+ return $text;
+ }
+ /**
+ * Prepares the size parameter for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param MediaOrientationEnum|null $orientation The desired media orientation.
+ * @param string|null $aspectRatio The desired media aspect ratio.
+ * @return string The prepared size parameter.
+ */
+ protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string
+ {
+ // Use aspect ratio if set, as it is more specific.
+ if ($aspectRatio !== null) {
+ switch ($aspectRatio) {
+ case '1:1':
+ return '1024x1024';
+ case '3:2':
+ return '1536x1024';
+ case '7:4':
+ return '1792x1024';
+ case '2:3':
+ return '1024x1536';
+ case '4:7':
+ return '1024x1792';
+ default:
+ throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not supported.');
+ }
+ }
+ // This should always have a value, as the method is only called if at least one or the other is set.
+ if ($orientation !== null) {
+ if ($orientation->isLandscape()) {
+ return '1536x1024';
+ }
+ if ($orientation->isPortrait()) {
+ return '1024x1536';
+ }
+ }
+ return '1024x1024';
+ }
+ /**
+ * Creates a request object for the provider's API.
+ *
+ * Implementations should use $this->getRequestOptions() to attach any
+ * configured request options to the Request.
+ *
+ * @since 0.1.0
+ *
+ * @param HttpMethodEnum $method The HTTP method.
+ * @param string $path The API endpoint path, relative to the base URI.
+ * @param array> $headers The request headers.
+ * @param string|array|null $data The request data.
+ * @return Request The request object.
+ */
+ abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
+ /**
+ * Throws an exception if the response is not successful.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The HTTP response to check.
+ * @throws ResponseException If the response is not successful.
+ */
+ protected function throwIfNotSuccessful(Response $response): void
+ {
+ /*
+ * While this method only calls the utility method, it's important to have it here as a protected method so
+ * that child classes can override it if needed.
+ */
+ ResponseUtil::throwIfNotSuccessful($response);
+ }
+ /**
+ * Parses the response from the API endpoint to a generative AI result.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The response from the API endpoint.
+ * @param string $expectedMimeType The expected MIME type the response is in.
+ * @return GenerativeAiResult The parsed generative AI result.
+ */
+ protected function parseResponseToGenerativeAiResult(Response $response, string $expectedMimeType = 'image/png'): GenerativeAiResult
+ {
+ /** @var ResponseData $responseData */
+ $responseData = $response->getData();
+ if (!isset($responseData['data']) || !$responseData['data']) {
+ throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data');
+ }
+ if (!is_array($responseData['data'])) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'data', 'The value must be an array.');
+ }
+ $candidates = [];
+ foreach ($responseData['data'] as $index => $choiceData) {
+ if (!is_array($choiceData) || array_is_list($choiceData)) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", 'The value must be an associative array.');
+ }
+ $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType);
+ }
+ $id = $this->getResultId($responseData);
+ if (isset($responseData['usage']) && is_array($responseData['usage'])) {
+ $usage = $responseData['usage'];
+ $tokenUsage = new TokenUsage($usage['input_tokens'] ?? 0, $usage['output_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
+ } else {
+ $tokenUsage = new TokenUsage(0, 0, 0);
+ }
+ // Use any other data from the response as provider-specific response metadata.
+ $providerMetadata = $responseData;
+ unset($providerMetadata['id'], $providerMetadata['data'], $providerMetadata['usage']);
+ return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $providerMetadata);
+ }
+ /**
+ * Parses a single choice from the API response into a Candidate object.
+ *
+ * @since 0.1.0
+ *
+ * @param ChoiceData $choiceData The choice data from the API response.
+ * @param int $index The index of the choice in the choices array.
+ * @param string $expectedMimeType The expected MIME type the response is in.
+ * @return Candidate The parsed candidate.
+ * @throws RuntimeException If the choice data is invalid.
+ */
+ protected function parseResponseChoiceToCandidate(array $choiceData, int $index, string $expectedMimeType = 'image/png'): Candidate
+ {
+ if (isset($choiceData['url']) && is_string($choiceData['url'])) {
+ $imageFile = new File($choiceData['url'], $expectedMimeType);
+ } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) {
+ $imageFile = new File($choiceData['b64_json'], $expectedMimeType);
+ } else {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must contain either a url or b64_json key with a string value.');
+ }
+ $parts = [new MessagePart($imageFile)];
+ $message = new Message(MessageRoleEnum::model(), $parts);
+ return new Candidate($message, FinishReasonEnum::stop());
+ }
+ /**
+ * Extracts the result ID from the API response data.
+ *
+ * @since 0.4.0
+ *
+ * @param array $responseData The response data from the API.
+ * @return string The result ID.
+ */
+ protected function getResultId(array $responseData): string
+ {
+ return isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php
new file mode 100644
index 0000000000000..cc5e0e9ab1df9
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php
@@ -0,0 +1,80 @@
+getHttpTransporter();
+ $request = $this->createRequest(HttpMethodEnum::GET(), 'models');
+ $request = $this->getRequestAuthentication()->authenticateRequest($request);
+ $response = $httpTransporter->send($request);
+ $this->throwIfNotSuccessful($response);
+ $modelsMetadataList = $this->parseResponseToModelMetadataList($response);
+ $modelMetadataMap = [];
+ foreach ($modelsMetadataList as $modelMetadata) {
+ $modelMetadataMap[$modelMetadata->getId()] = $modelMetadata;
+ }
+ return $modelMetadataMap;
+ }
+ /**
+ * Creates a request object for the provider's API.
+ *
+ * @since 0.1.0
+ *
+ * @param HttpMethodEnum $method The HTTP method.
+ * @param string $path The API endpoint path, relative to the base URI.
+ * @param array> $headers The request headers.
+ * @param string|array|null $data The request data.
+ * @return Request The request object.
+ */
+ abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
+ /**
+ * Throws an exception if the response is not successful.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The HTTP response to check.
+ * @throws ResponseException If the response is not successful.
+ */
+ protected function throwIfNotSuccessful(Response $response): void
+ {
+ /*
+ * While this method only calls the utility method, it's important to have it here as a protected method so
+ * that child classes can override it if needed.
+ */
+ ResponseUtil::throwIfNotSuccessful($response);
+ }
+ /**
+ * Parses the response from the API endpoint to list models into a list of model metadata objects.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The response from the API endpoint to list models.
+ * @return list List of model metadata objects.
+ */
+ abstract protected function parseResponseToModelMetadataList(Response $response): array;
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
new file mode 100644
index 0000000000000..adbbd5dad9f49
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
@@ -0,0 +1,557 @@
+
+ * }
+ * }
+ * @phpstan-type MessageData array{
+ * role?: string,
+ * reasoning_content?: string,
+ * content?: string,
+ * tool_calls?: list
+ * }
+ * @phpstan-type ChoiceData array{
+ * message?: MessageData,
+ * finish_reason?: string
+ * }
+ * @phpstan-type UsageData array{
+ * prompt_tokens?: int,
+ * completion_tokens?: int,
+ * total_tokens?: int
+ * }
+ * @phpstan-type ResponseData array{
+ * id?: string,
+ * choices?: list,
+ * usage?: UsageData
+ * }
+ */
+abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface
+{
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ final public function generateTextResult(array $prompt): GenerativeAiResult
+ {
+ $httpTransporter = $this->getHttpTransporter();
+ $params = $this->prepareGenerateTextParams($prompt);
+ $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', ['Content-Type' => 'application/json'], $params);
+ // Add authentication credentials to the request.
+ $request = $this->getRequestAuthentication()->authenticateRequest($request);
+ // Send and process the request.
+ $response = $httpTransporter->send($request);
+ $this->throwIfNotSuccessful($response);
+ return $this->parseResponseToGenerativeAiResult($response);
+ }
+ /**
+ * Prepares the given prompt and the model configuration into parameters for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param list $prompt The prompt to generate text for. Either a single message or a list of messages
+ * from a chat.
+ * @return array The parameters for the API request.
+ */
+ protected function prepareGenerateTextParams(array $prompt): array
+ {
+ $config = $this->getConfig();
+ $params = ['model' => $this->metadata()->getId(), 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction())];
+ $outputModalities = $config->getOutputModalities();
+ if (is_array($outputModalities)) {
+ $this->validateOutputModalities($outputModalities);
+ if (count($outputModalities) > 1) {
+ $params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities);
+ }
+ }
+ $candidateCount = $config->getCandidateCount();
+ if ($candidateCount !== null) {
+ $params['n'] = $candidateCount;
+ }
+ $maxTokens = $config->getMaxTokens();
+ if ($maxTokens !== null) {
+ $params['max_tokens'] = $maxTokens;
+ }
+ $temperature = $config->getTemperature();
+ if ($temperature !== null) {
+ $params['temperature'] = $temperature;
+ }
+ $topP = $config->getTopP();
+ if ($topP !== null) {
+ $params['top_p'] = $topP;
+ }
+ $stopSequences = $config->getStopSequences();
+ if (is_array($stopSequences)) {
+ $params['stop'] = $stopSequences;
+ }
+ $presencePenalty = $config->getPresencePenalty();
+ if ($presencePenalty !== null) {
+ $params['presence_penalty'] = $presencePenalty;
+ }
+ $frequencyPenalty = $config->getFrequencyPenalty();
+ if ($frequencyPenalty !== null) {
+ $params['frequency_penalty'] = $frequencyPenalty;
+ }
+ $logprobs = $config->getLogprobs();
+ if ($logprobs !== null) {
+ $params['logprobs'] = $logprobs;
+ }
+ $topLogprobs = $config->getTopLogprobs();
+ if ($topLogprobs !== null) {
+ $params['top_logprobs'] = $topLogprobs;
+ }
+ $functionDeclarations = $config->getFunctionDeclarations();
+ if (is_array($functionDeclarations)) {
+ $params['tools'] = $this->prepareToolsParam($functionDeclarations);
+ }
+ $outputMimeType = $config->getOutputMimeType();
+ if ('application/json' === $outputMimeType) {
+ $outputSchema = $config->getOutputSchema();
+ $params['response_format'] = $this->prepareResponseFormatParam($outputSchema);
+ }
+ /*
+ * Any custom options are added to the parameters as well.
+ * This allows developers to pass other options that may be more niche or not yet supported by the SDK.
+ */
+ $customOptions = $config->getCustomOptions();
+ foreach ($customOptions as $key => $value) {
+ if (isset($params[$key])) {
+ throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
+ }
+ $params[$key] = $value;
+ }
+ return $params;
+ }
+ /**
+ * Prepares the messages parameter for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param list $messages The messages to prepare.
+ * @param string|null $systemInstruction An optional system instruction to prepend to the messages.
+ * @return list> The prepared messages parameter.
+ */
+ protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array
+ {
+ $messagesParam = array_map(function (Message $message): array {
+ // Special case: Function response.
+ $messageParts = $message->getParts();
+ if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) {
+ $functionResponse = $messageParts[0]->getFunctionResponse();
+ if (!$functionResponse) {
+ // This should be impossible due to class internals, but still needs to be checked.
+ throw new RuntimeException('The function response typed message part must contain a function response.');
+ }
+ return ['role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId()];
+ }
+ $messageData = ['role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map([$this, 'getMessagePartContentData'], $messageParts)))];
+ // Only include tool_calls if there are any (OpenAI rejects empty arrays).
+ $toolCalls = array_values(array_filter(array_map([$this, 'getMessagePartToolCallData'], $messageParts)));
+ if (!empty($toolCalls)) {
+ $messageData['tool_calls'] = $toolCalls;
+ }
+ return $messageData;
+ }, $messages);
+ if ($systemInstruction) {
+ array_unshift($messagesParam, [
+ /*
+ * TODO: Replace this with 'developer' in the future.
+ * See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages
+ */
+ 'role' => 'system',
+ 'content' => [['type' => 'text', 'text' => $systemInstruction]],
+ ]);
+ }
+ return $messagesParam;
+ }
+ /**
+ * Returns the OpenAI API specific role string for the given message role.
+ *
+ * @since 0.1.0
+ *
+ * @param MessageRoleEnum $role The message role.
+ * @return string The role for the API request.
+ */
+ protected function getMessageRoleString(MessageRoleEnum $role): string
+ {
+ if ($role === MessageRoleEnum::model()) {
+ return 'assistant';
+ }
+ return 'user';
+ }
+ /**
+ * Returns the OpenAI API specific content data for a message part.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart $part The message part to get the data for.
+ * @return ?array The data for the message content part, or null if not applicable.
+ * @throws InvalidArgumentException If the message part type or data is unsupported.
+ */
+ protected function getMessagePartContentData(MessagePart $part): ?array
+ {
+ $type = $part->getType();
+ if ($type->isText()) {
+ /*
+ * The OpenAI Chat Completions API spec does not support annotating thought parts as input,
+ * so we instead skip them.
+ */
+ if ($part->getChannel()->isThought()) {
+ return null;
+ }
+ return ['type' => 'text', 'text' => $part->getText()];
+ }
+ if ($type->isFile()) {
+ $file = $part->getFile();
+ if (!$file) {
+ // This should be impossible due to class internals, but still needs to be checked.
+ throw new RuntimeException('The file typed message part must contain a file.');
+ }
+ if ($file->isRemote()) {
+ if ($file->isImage()) {
+ return ['type' => 'image_url', 'image_url' => ['url' => $file->getUrl()]];
+ }
+ throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for remote file message part.', $file->getMimeType()));
+ }
+ // Else, it is an inline file.
+ if ($file->isImage()) {
+ return ['type' => 'image_url', 'image_url' => ['url' => $file->getDataUri()]];
+ }
+ if ($file->isAudio()) {
+ return ['type' => 'input_audio', 'input_audio' => ['data' => $file->getBase64Data(), 'format' => $file->getMimeTypeObject()->toExtension()]];
+ }
+ throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for inline file message part.', $file->getMimeType()));
+ }
+ if ($type->isFunctionCall()) {
+ // Skip, as this is separately included. See `getMessagePartToolCallData()`.
+ return null;
+ }
+ if ($type->isFunctionResponse()) {
+ // Special case: Function response.
+ throw new InvalidArgumentException('The API only allows a single function response, as the only content of the message.');
+ }
+ throw new InvalidArgumentException(sprintf('Unsupported message part type "%s".', $type));
+ }
+ /**
+ * Returns the OpenAI API specific tool calls data for a message part.
+ *
+ * @since 0.1.0
+ *
+ * @param MessagePart $part The message part to get the data for.
+ * @return ?array The data for the message tool call part, or null if not applicable.
+ * @throws InvalidArgumentException If the message part type or data is unsupported.
+ */
+ protected function getMessagePartToolCallData(MessagePart $part): ?array
+ {
+ $type = $part->getType();
+ if ($type->isFunctionCall()) {
+ $functionCall = $part->getFunctionCall();
+ if (!$functionCall) {
+ // This should be impossible due to class internals, but still needs to be checked.
+ throw new RuntimeException('The function call typed message part must contain a function call.');
+ }
+ $args = $functionCall->getArgs();
+ /*
+ * Ensure null or empty arrays become empty objects for JSON encoding.
+ * While in theory the JSON schema could also dictate a type of
+ * 'array', in practice function arguments are typically of type
+ * 'object'. More importantly, the OpenAI API specification seems
+ * to expect that, and does not support passing arrays as the root
+ * value. The null check handles the case where FunctionCall normalizes
+ * empty arrays to null.
+ */
+ if ($args === null || is_array($args) && count($args) === 0) {
+ $args = new \stdClass();
+ }
+ return ['type' => 'function', 'id' => $functionCall->getId(), 'function' => ['name' => $functionCall->getName(), 'arguments' => json_encode($args)]];
+ }
+ // All other types are handled in `getMessagePartContentData()`.
+ return null;
+ }
+ /**
+ * Validates that the given output modalities to ensure that at least one output modality is text.
+ *
+ * @since 0.1.0
+ *
+ * @param array $outputModalities The output modalities to validate.
+ * @throws InvalidArgumentException If no text output modality is present.
+ */
+ protected function validateOutputModalities(array $outputModalities): void
+ {
+ // If no output modalities are set, it's fine, as we can assume text.
+ if (count($outputModalities) === 0) {
+ return;
+ }
+ foreach ($outputModalities as $modality) {
+ if ($modality->isText()) {
+ return;
+ }
+ }
+ throw new InvalidArgumentException('A text output modality must be present when generating text.');
+ }
+ /**
+ * Prepares the output modalities parameter for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param array $modalities The modalities to prepare.
+ * @return list The prepared modalities parameter.
+ */
+ protected function prepareOutputModalitiesParam(array $modalities): array
+ {
+ $prepared = [];
+ foreach ($modalities as $modality) {
+ if ($modality->isText()) {
+ $prepared[] = 'text';
+ } elseif ($modality->isImage()) {
+ $prepared[] = 'image';
+ } elseif ($modality->isAudio()) {
+ $prepared[] = 'audio';
+ } else {
+ throw new InvalidArgumentException(sprintf('Unsupported output modality "%s".', $modality));
+ }
+ }
+ return $prepared;
+ }
+ /**
+ * Prepares the tools parameter for the API request.
+ *
+ * @since 0.1.0
+ *
+ * @param list $functionDeclarations The function declarations.
+ * @return list> The prepared tools parameter.
+ */
+ protected function prepareToolsParam(array $functionDeclarations): array
+ {
+ $tools = [];
+ foreach ($functionDeclarations as $functionDeclaration) {
+ $tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()];
+ }
+ return $tools;
+ }
+ /**
+ * Prepares the response format parameter for the API request.
+ *
+ * This is only called if the output MIME type is `application/json`.
+ *
+ * @since 0.1.0
+ *
+ * @param array|null $outputSchema The output schema.
+ * @return array The prepared response format parameter.
+ */
+ protected function prepareResponseFormatParam(?array $outputSchema): array
+ {
+ if (is_array($outputSchema)) {
+ return ['type' => 'json_schema', 'json_schema' => $outputSchema];
+ }
+ return ['type' => 'json_object'];
+ }
+ /**
+ * Creates a request object for the provider's API.
+ *
+ * Implementations should use $this->getRequestOptions() to attach any
+ * configured request options to the Request.
+ *
+ * @since 0.1.0
+ *
+ * @param HttpMethodEnum $method The HTTP method.
+ * @param string $path The API endpoint path, relative to the base URI.
+ * @param array> $headers The request headers.
+ * @param string|array|null $data The request data.
+ * @return Request The request object.
+ */
+ abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
+ /**
+ * Throws an exception if the response is not successful.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The HTTP response to check.
+ * @throws ResponseException If the response is not successful.
+ */
+ protected function throwIfNotSuccessful(Response $response): void
+ {
+ /*
+ * While this method only calls the utility method, it's important to have it here as a protected method so
+ * that child classes can override it if needed.
+ */
+ ResponseUtil::throwIfNotSuccessful($response);
+ }
+ /**
+ * Parses the response from the API endpoint to a generative AI result.
+ *
+ * @since 0.1.0
+ *
+ * @param Response $response The response from the API endpoint.
+ * @return GenerativeAiResult The parsed generative AI result.
+ */
+ protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult
+ {
+ /** @var ResponseData $responseData */
+ $responseData = $response->getData();
+ if (!isset($responseData['choices']) || !$responseData['choices']) {
+ throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices');
+ }
+ if (!is_array($responseData['choices'])) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'choices', 'The value must be an array.');
+ }
+ $candidates = [];
+ foreach ($responseData['choices'] as $index => $choiceData) {
+ if (!is_array($choiceData) || array_is_list($choiceData)) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must be an associative array.');
+ }
+ $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index);
+ }
+ $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
+ if (isset($responseData['usage']) && is_array($responseData['usage'])) {
+ $usage = $responseData['usage'];
+ $tokenUsage = new TokenUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
+ } else {
+ $tokenUsage = new TokenUsage(0, 0, 0);
+ }
+ // Use any other data from the response as provider-specific response metadata.
+ $additionalData = $responseData;
+ unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']);
+ return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $additionalData);
+ }
+ /**
+ * Parses a single choice from the API response into a Candidate object.
+ *
+ * @since 0.1.0
+ *
+ * @param ChoiceData $choiceData The choice data from the API response.
+ * @param int $index The index of the choice in the choices array.
+ * @return Candidate The parsed candidate.
+ * @throws RuntimeException If the choice data is invalid.
+ */
+ protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate
+ {
+ if (!isset($choiceData['message']) || !is_array($choiceData['message']) || array_is_list($choiceData['message'])) {
+ throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].message");
+ }
+ if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) {
+ throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason");
+ }
+ $messageData = $choiceData['message'];
+ $message = $this->parseResponseChoiceMessage($messageData, $index);
+ switch ($choiceData['finish_reason']) {
+ case 'stop':
+ $finishReason = FinishReasonEnum::stop();
+ break;
+ case 'length':
+ $finishReason = FinishReasonEnum::length();
+ break;
+ case 'content_filter':
+ $finishReason = FinishReasonEnum::contentFilter();
+ break;
+ case 'tool_calls':
+ $finishReason = FinishReasonEnum::toolCalls();
+ break;
+ default:
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason']));
+ }
+ return new Candidate($message, $finishReason);
+ }
+ /**
+ * Parses the message from a choice in the API response.
+ *
+ * @since 0.1.0
+ *
+ * @param MessageData $messageData The message data from the API response.
+ * @param int $index The index of the choice in the choices array.
+ * @return Message The parsed message.
+ */
+ protected function parseResponseChoiceMessage(array $messageData, int $index): Message
+ {
+ $role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model();
+ $parts = $this->parseResponseChoiceMessageParts($messageData, $index);
+ return new Message($role, $parts);
+ }
+ /**
+ * Parses the message parts from a choice in the API response.
+ *
+ * @since 0.1.0
+ *
+ * @param MessageData $messageData The message data from the API response.
+ * @param int $index The index of the choice in the choices array.
+ * @return MessagePart[] The parsed message parts.
+ */
+ protected function parseResponseChoiceMessageParts(array $messageData, int $index): array
+ {
+ $parts = [];
+ if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) {
+ $parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought());
+ }
+ if (isset($messageData['content']) && is_string($messageData['content'])) {
+ $parts[] = new MessagePart($messageData['content']);
+ }
+ if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) {
+ foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) {
+ $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData);
+ if (!$toolCallPart) {
+ throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.');
+ }
+ $parts[] = $toolCallPart;
+ }
+ }
+ return $parts;
+ }
+ /**
+ * Parses a tool call part from the API response.
+ *
+ * @since 0.1.0
+ *
+ * @param ToolCallData $toolCallData The tool call data from the API response.
+ * @return MessagePart|null The parsed message part for the tool call, or null if not applicable.
+ */
+ protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart
+ {
+ /*
+ * For now, only function calls are supported.
+ *
+ * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set.
+ */
+ if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) {
+ return null;
+ }
+ $functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], \true) : $toolCallData['function']['arguments'];
+ $functionCall = new FunctionCall(isset($toolCallData['id']) && is_string($toolCallData['id']) ? $toolCallData['id'] : null, isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? $toolCallData['function']['name'] : null, $functionArguments);
+ return new MessagePart($functionCall);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php b/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php
new file mode 100644
index 0000000000000..107e303af33f7
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php
@@ -0,0 +1,520 @@
+> Mapping of provider IDs to class names.
+ */
+ private array $registeredIdsToClassNames = [];
+ /**
+ * @var array, string> Mapping of provider class names to IDs.
+ */
+ private array $registeredClassNamesToIds = [];
+ /**
+ * @var array, RequestAuthenticationInterface> Mapping of provider class names to
+ * authentication instances.
+ */
+ private array $providerAuthenticationInstances = [];
+ /**
+ * Registers a provider class with the registry.
+ *
+ * @since 0.1.0
+ *
+ * @param class-string $className The fully qualified provider class name implementing the
+ * ProviderInterface
+ * @throws InvalidArgumentException If the class doesn't exist or implement the required interface.
+ */
+ public function registerProvider(string $className): void
+ {
+ if (!class_exists($className)) {
+ throw new InvalidArgumentException(sprintf('Provider class does not exist: %s', $className));
+ }
+ // Validate that class implements ProviderInterface
+ if (!is_subclass_of($className, ProviderInterface::class)) {
+ throw new InvalidArgumentException(sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className));
+ }
+ $metadata = $className::metadata();
+ if (!$metadata instanceof ProviderMetadata) {
+ throw new InvalidArgumentException(sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className));
+ }
+ // If there is already a HTTP transporter instance set, hook it up to the provider as needed.
+ try {
+ $httpTransporter = $this->getHttpTransporter();
+ } catch (RuntimeException $e) {
+ /*
+ * If this fails, it's okay. There is no defined sequence between setting the HTTP transporter in the
+ * registry and registering providers in it, so it might be that the transporter is set later. It will be
+ * hooked up then.
+ * But for now we can ignore this exception and attempt to set the default HTTP transporter, if possible.
+ */
+ try {
+ $this->setHttpTransporter(HttpTransporterFactory::createTransporter());
+ $httpTransporter = $this->getHttpTransporter();
+ } catch (DiscoveryNotFoundException $e) {
+ /*
+ * If no HTTP client implementation can be discovered yet, we can ignore this for now.
+ * It might be set later, so it's not a hard error at this point.
+ * We'll try again the next time a provider is registered, or maybe by that time an explicit
+ * HTTP transporter will have been set.
+ */
+ }
+ }
+ if (isset($httpTransporter)) {
+ $this->setHttpTransporterForProvider($className, $httpTransporter);
+ }
+ // Hook up the request authentication instance, using a default if not set.
+ if (!isset($this->providerAuthenticationInstances[$className])) {
+ $defaultProviderAuthentication = $this->createDefaultProviderRequestAuthentication($className);
+ if ($defaultProviderAuthentication !== null) {
+ $this->providerAuthenticationInstances[$className] = $defaultProviderAuthentication;
+ }
+ }
+ if (isset($this->providerAuthenticationInstances[$className])) {
+ $this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]);
+ }
+ $this->registeredIdsToClassNames[$metadata->getId()] = $className;
+ $this->registeredClassNamesToIds[$className] = $metadata->getId();
+ }
+ /**
+ * Gets a list of all registered provider IDs.
+ *
+ * @since 0.1.0
+ *
+ * @return list List of registered provider IDs.
+ */
+ public function getRegisteredProviderIds(): array
+ {
+ return array_keys($this->registeredIdsToClassNames);
+ }
+ /**
+ * Checks if a provider is registered.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name to check.
+ * @return bool True if the provider is registered.
+ */
+ public function hasProvider(string $idOrClassName): bool
+ {
+ return $this->isRegisteredId($idOrClassName) || $this->isRegisteredClassName($idOrClassName);
+ }
+ /**
+ * Gets the class name for a registered provider.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @return class-string The provider class name.
+ * @throws InvalidArgumentException If the provider is not registered.
+ */
+ public function getProviderClassName(string $idOrClassName): string
+ {
+ // If it's already a class name, return it
+ if ($this->isRegisteredClassName($idOrClassName)) {
+ return $idOrClassName;
+ }
+ // If it's a registered ID, return its class name
+ if ($this->isRegisteredId($idOrClassName)) {
+ return $this->registeredIdsToClassNames[$idOrClassName];
+ }
+ // Not found
+ throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
+ }
+ /**
+ * Gets the provider ID for a registered provider.
+ *
+ * @since 0.2.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @return string The provider ID.
+ * @throws InvalidArgumentException If the provider is not registered.
+ */
+ public function getProviderId(string $idOrClassName): string
+ {
+ // If it's already an ID, return it
+ if ($this->isRegisteredId($idOrClassName)) {
+ return $idOrClassName;
+ }
+ // If it's a registered class name, return its ID
+ if ($this->isRegisteredClassName($idOrClassName)) {
+ return $this->registeredClassNamesToIds[$idOrClassName];
+ }
+ // Not found
+ throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
+ }
+ /**
+ * Checks if a provider is properly configured.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @return bool True if the provider is configured and ready to use.
+ */
+ public function isProviderConfigured(string $idOrClassName): bool
+ {
+ try {
+ $className = $this->resolveProviderClassName($idOrClassName);
+ // Use static method from ProviderInterface
+ /** @var class-string $className */
+ $availability = $className::availability();
+ return $availability->isConfigured();
+ } catch (InvalidArgumentException $e) {
+ return \false;
+ }
+ }
+ /**
+ * Finds models across all available providers that support the given requirements.
+ *
+ * @since 0.1.0
+ *
+ * @param ModelRequirements $modelRequirements The requirements to match against.
+ * @return list List of provider models metadata that match requirements.
+ */
+ public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array
+ {
+ $results = [];
+ foreach ($this->registeredIdsToClassNames as $providerId => $className) {
+ $providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements);
+ if (!empty($providerResults)) {
+ // Use static method from ProviderInterface
+ /** @var class-string $className */
+ $providerMetadata = $className::metadata();
+ $results[] = new ProviderModelsMetadata($providerMetadata, $providerResults);
+ }
+ }
+ return $results;
+ }
+ /**
+ * Finds models within a specific available provider that support the given requirements.
+ *
+ * @since 0.1.0
+ *
+ * @param string $idOrClassName The provider ID or class name.
+ * @param ModelRequirements $modelRequirements The requirements to match against.
+ * @return list List of model metadata that match requirements.
+ */
+ public function findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements): array
+ {
+ $className = $this->resolveProviderClassName($idOrClassName);
+ // If the provider is not configured, there is no way to use it, so it is considered unavailable.
+ if (!$this->isProviderConfigured($className)) {
+ return [];
+ }
+ $modelMetadataDirectory = $className::modelMetadataDirectory();
+ // Filter models that meet requirements
+ $matchingModels = [];
+ foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) {
+ if ($modelRequirements->areMetBy($modelMetadata)) {
+ $matchingModels[] = $modelMetadata;
+ }
+ }
+ return $matchingModels;
+ }
+ /**
+ * Gets a configured model instance from a provider.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @param string $modelId The model identifier.
+ * @param ModelConfig|null $modelConfig The model configuration.
+ * @return ModelInterface The configured model instance.
+ * @throws InvalidArgumentException If provider or model is not found.
+ */
+ public function getProviderModel(string $idOrClassName, string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
+ {
+ $className = $this->resolveProviderClassName($idOrClassName);
+ $modelInstance = $className::model($modelId, $modelConfig);
+ $this->bindModelDependencies($modelInstance);
+ return $modelInstance;
+ }
+ /**
+ * Binds dependencies to a model instance.
+ *
+ * This method injects required dependencies such as HTTP transporter
+ * and authentication into model instances that need them.
+ *
+ * @since 0.1.0
+ *
+ * @param ModelInterface $modelInstance The model instance to bind dependencies to.
+ * @return void
+ */
+ public function bindModelDependencies(ModelInterface $modelInstance): void
+ {
+ $className = $this->resolveProviderClassName($modelInstance->providerMetadata()->getId());
+ if ($modelInstance instanceof WithHttpTransporterInterface) {
+ $modelInstance->setHttpTransporter($this->getHttpTransporter());
+ }
+ if ($modelInstance instanceof WithRequestAuthenticationInterface) {
+ $requestAuthentication = $this->getProviderRequestAuthentication($className);
+ if ($requestAuthentication !== null) {
+ $modelInstance->setRequestAuthentication($requestAuthentication);
+ }
+ }
+ }
+ /**
+ * Gets the class name for a registered provider (handles both ID and class name input).
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @return class-string The provider class name.
+ * @throws InvalidArgumentException If provider is not registered.
+ */
+ private function resolveProviderClassName(string $idOrClassName): string
+ {
+ // If it's already a class name, return it
+ if ($this->isRegisteredClassName($idOrClassName)) {
+ return $idOrClassName;
+ }
+ // If it's a registered ID, return its class name
+ if ($this->isRegisteredId($idOrClassName)) {
+ return $this->registeredIdsToClassNames[$idOrClassName];
+ }
+ // Not found
+ throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void
+ {
+ $this->setHttpTransporterOriginal($httpTransporter);
+ // Make sure all registered providers have the HTTP transporter hooked up as needed.
+ foreach ($this->registeredIdsToClassNames as $className) {
+ $this->setHttpTransporterForProvider($className, $httpTransporter);
+ }
+ }
+ /**
+ * Sets the request authentication instance for the given provider.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @param RequestAuthenticationInterface $requestAuthentication The request authentication instance.
+ */
+ public function setProviderRequestAuthentication(string $idOrClassName, RequestAuthenticationInterface $requestAuthentication): void
+ {
+ $className = $this->resolveProviderClassName($idOrClassName);
+ $this->providerAuthenticationInstances[$className] = $requestAuthentication;
+ $this->setRequestAuthenticationForProvider($className, $requestAuthentication);
+ }
+ /**
+ * Gets the request authentication instance for the given provider, if set.
+ *
+ * @since 0.1.0
+ *
+ * @param string|class-string $idOrClassName The provider ID or class name.
+ * @return ?RequestAuthenticationInterface The request authentication instance, or null if not set.
+ */
+ public function getProviderRequestAuthentication(string $idOrClassName): ?RequestAuthenticationInterface
+ {
+ $className = $this->resolveProviderClassName($idOrClassName);
+ if (!isset($this->providerAuthenticationInstances[$className])) {
+ return null;
+ }
+ return $this->providerAuthenticationInstances[$className];
+ }
+ /**
+ * Sets the HTTP transporter for a specific provider, hooking up its class instances.
+ *
+ * @since 0.1.0
+ *
+ * @param class-string $className The provider class name.
+ * @param HttpTransporterInterface $httpTransporter The HTTP transporter instance.
+ */
+ private function setHttpTransporterForProvider(string $className, HttpTransporterInterface $httpTransporter): void
+ {
+ $availability = $className::availability();
+ if ($availability instanceof WithHttpTransporterInterface) {
+ $availability->setHttpTransporter($httpTransporter);
+ }
+ $modelMetadataDirectory = $className::modelMetadataDirectory();
+ if ($modelMetadataDirectory instanceof WithHttpTransporterInterface) {
+ $modelMetadataDirectory->setHttpTransporter($httpTransporter);
+ }
+ if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
+ $operationsHandler = $className::operationsHandler();
+ if ($operationsHandler instanceof WithHttpTransporterInterface) {
+ $operationsHandler->setHttpTransporter($httpTransporter);
+ }
+ }
+ }
+ /**
+ * Sets the request authentication for a specific provider, hooking up its class instances.
+ *
+ * @since 0.1.0
+ *
+ * @param class-string $className The provider class name.
+ * @param RequestAuthenticationInterface $requestAuthentication The authentication instance.
+ *
+ * @throws InvalidArgumentException If the authentication instance is not of the expected type.
+ */
+ private function setRequestAuthenticationForProvider(string $className, RequestAuthenticationInterface $requestAuthentication): void
+ {
+ $authenticationMethod = $className::metadata()->getAuthenticationMethod();
+ if ($authenticationMethod === null) {
+ throw new InvalidArgumentException(sprintf('Provider %s does not expect any authentication, but got %s.', $className, get_class($requestAuthentication)));
+ }
+ $expectedClass = $authenticationMethod->getImplementationClass();
+ if (!$requestAuthentication instanceof $expectedClass) {
+ throw new InvalidArgumentException(sprintf('Provider %s expects authentication of type %s, but got %s.', $className, $expectedClass, get_class($requestAuthentication)));
+ }
+ $availability = $className::availability();
+ if ($availability instanceof WithRequestAuthenticationInterface) {
+ $availability->setRequestAuthentication($requestAuthentication);
+ }
+ $modelMetadataDirectory = $className::modelMetadataDirectory();
+ if ($modelMetadataDirectory instanceof WithRequestAuthenticationInterface) {
+ $modelMetadataDirectory->setRequestAuthentication($requestAuthentication);
+ }
+ if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
+ $operationsHandler = $className::operationsHandler();
+ if ($operationsHandler instanceof WithRequestAuthenticationInterface) {
+ $operationsHandler->setRequestAuthentication($requestAuthentication);
+ }
+ }
+ }
+ /**
+ * Creates a default request authentication instance for a provider.
+ *
+ * @since 0.1.0
+ *
+ * @param class-string $className The provider class name.
+ * @return ?RequestAuthenticationInterface The default request authentication instance, or null if not required or
+ * if no credential data can be found.
+ */
+ private function createDefaultProviderRequestAuthentication(string $className): ?RequestAuthenticationInterface
+ {
+ $providerMetadata = $className::metadata();
+ $providerId = $providerMetadata->getId();
+ $authenticationMethod = $providerMetadata->getAuthenticationMethod();
+ if ($authenticationMethod === null) {
+ return null;
+ }
+ $authenticationClass = $authenticationMethod->getImplementationClass();
+ if ($authenticationClass === null) {
+ return null;
+ }
+ $authenticationSchema = $authenticationClass::getJsonSchema();
+ // Iterate over all JSON schema object properties to try to determine the necessary authentication data.
+ $authenticationData = [];
+ if (isset($authenticationSchema['properties']) && is_array($authenticationSchema['properties'])) {
+ /** @var array $details */
+ foreach ($authenticationSchema['properties'] as $property => $details) {
+ $envVarName = $this->getEnvVarName($providerId, $property);
+ // Try to get the value from environment variable or constant.
+ $envValue = getenv($envVarName);
+ if ($envValue === \false) {
+ if (!defined($envVarName)) {
+ continue;
+ // Skip if neither environment variable nor constant is defined.
+ }
+ $envValue = constant($envVarName);
+ if (!is_scalar($envValue)) {
+ continue;
+ }
+ }
+ if (isset($details['type'])) {
+ switch ($details['type']) {
+ case 'boolean':
+ $authenticationData[$property] = filter_var($envValue, \FILTER_VALIDATE_BOOLEAN);
+ break;
+ case 'number':
+ $authenticationData[$property] = (int) $envValue;
+ break;
+ case 'string':
+ default:
+ $authenticationData[$property] = (string) $envValue;
+ }
+ } else {
+ // Default to string if no type is specified.
+ $authenticationData[$property] = (string) $envValue;
+ }
+ }
+ // If any required fields are missing, return null to avoid immediate errors.
+ if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) {
+ /** @var list $requiredProperties */
+ $requiredProperties = $authenticationSchema['required'];
+ if (array_diff_key(array_flip($requiredProperties), $authenticationData)) {
+ return null;
+ }
+ }
+ }
+ /** @var RequestAuthenticationInterface */
+ /** @var array $authenticationData */
+ return $authenticationClass::fromArray($authenticationData);
+ }
+ /**
+ * Checks if the given value is a registered provider class name.
+ *
+ * @since 0.4.0
+ *
+ * @param string $idOrClassName The value to check.
+ * @return bool True if it's a registered class name.
+ * @phpstan-assert-if-true class-string $idOrClassName
+ */
+ private function isRegisteredClassName(string $idOrClassName): bool
+ {
+ return isset($this->registeredClassNamesToIds[$idOrClassName]);
+ }
+ /**
+ * Checks if the given value is a registered provider ID.
+ *
+ * @since 0.4.0
+ *
+ * @param string $idOrClassName The value to check.
+ * @return bool True if it's a registered provider ID.
+ */
+ private function isRegisteredId(string $idOrClassName): bool
+ {
+ return isset($this->registeredIdsToClassNames[$idOrClassName]);
+ }
+ /**
+ * Converts a provider ID and field name to a constant case environment variable name.
+ *
+ * @since 0.1.0
+ *
+ * @param string $providerId The provider ID.
+ * @param string $field The field name.
+ * @return string The environment variable name in CONSTANT_CASE.
+ */
+ private function getEnvVarName(string $providerId, string $field): string
+ {
+ // Convert camelCase or kebab-case or snake_case to CONSTANT_CASE.
+ $constantCaseProviderId = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $providerId)));
+ $constantCaseField = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $field)));
+ return "{$constantCaseProviderId}_{$constantCaseField}";
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php b/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php
new file mode 100644
index 0000000000000..5a087ca8b3fbe
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php
@@ -0,0 +1,59 @@
+ Provider metadata.
+ */
+ public function getAdditionalData(): array;
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php b/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php
new file mode 100644
index 0000000000000..d1bf7e0782985
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php
@@ -0,0 +1,117 @@
+
+ */
+class Candidate extends AbstractDataTransferObject
+{
+ public const KEY_MESSAGE = 'message';
+ public const KEY_FINISH_REASON = 'finishReason';
+ /**
+ * @var Message The generated message.
+ */
+ private Message $message;
+ /**
+ * @var FinishReasonEnum The reason generation stopped.
+ */
+ private FinishReasonEnum $finishReason;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param Message $message The generated message.
+ * @param FinishReasonEnum $finishReason The reason generation stopped.
+ */
+ public function __construct(Message $message, FinishReasonEnum $finishReason)
+ {
+ if (!$message->getRole()->isModel()) {
+ throw new InvalidArgumentException('Message must be a model message.');
+ }
+ $this->message = $message;
+ $this->finishReason = $finishReason;
+ }
+ /**
+ * Gets the generated message.
+ *
+ * @since 0.1.0
+ *
+ * @return Message The message.
+ */
+ public function getMessage(): Message
+ {
+ return $this->message;
+ }
+ /**
+ * Gets the finish reason.
+ *
+ * @since 0.1.0
+ *
+ * @return FinishReasonEnum The finish reason.
+ */
+ public function getFinishReason(): FinishReasonEnum
+ {
+ return $this->finishReason;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_MESSAGE => Message::getJsonSchema(), self::KEY_FINISH_REASON => ['type' => 'string', 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.']], 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return CandidateArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_MESSAGE => $this->message->toArray(), self::KEY_FINISH_REASON => $this->finishReason->value];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON]);
+ $messageData = $array[self::KEY_MESSAGE];
+ return new self(Message::fromArray($messageData), FinishReasonEnum::from($array[self::KEY_FINISH_REASON]));
+ }
+ /**
+ * Performs a deep clone of the candidate.
+ *
+ * This method ensures that the message object is cloned to prevent
+ * modifications to the cloned candidate from affecting the original.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $this->message = clone $this->message;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php b/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php
new file mode 100644
index 0000000000000..0d1d0ccbc38c7
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php
@@ -0,0 +1,420 @@
+,
+ * tokenUsage: TokenUsageArrayShape,
+ * providerMetadata: ProviderMetadataArrayShape,
+ * modelMetadata: ModelMetadataArrayShape,
+ * additionalData?: array
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class GenerativeAiResult extends AbstractDataTransferObject implements ResultInterface
+{
+ public const KEY_ID = 'id';
+ public const KEY_CANDIDATES = 'candidates';
+ 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 Candidate[] The generated candidates.
+ */
+ private array $candidates;
+ /**
+ * @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 Additional data.
+ */
+ private array $additionalData;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $id Unique identifier for this result.
+ * @param Candidate[] $candidates The generated candidates.
+ * @param TokenUsage $tokenUsage Token usage statistics.
+ * @param ProviderMetadata $providerMetadata Provider metadata.
+ * @param ModelMetadata $modelMetadata Model metadata.
+ * @param array $additionalData Additional data.
+ * @throws InvalidArgumentException If no candidates provided.
+ */
+ public function __construct(string $id, array $candidates, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = [])
+ {
+ if (empty($candidates)) {
+ throw new InvalidArgumentException('At least one candidate must be provided');
+ }
+ $this->id = $id;
+ $this->candidates = $candidates;
+ $this->tokenUsage = $tokenUsage;
+ $this->providerMetadata = $providerMetadata;
+ $this->modelMetadata = $modelMetadata;
+ $this->additionalData = $additionalData;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+ /**
+ * Gets the generated candidates.
+ *
+ * @since 0.1.0
+ *
+ * @return Candidate[] The candidates.
+ */
+ public function getCandidates(): array
+ {
+ return $this->candidates;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage
+ {
+ return $this->tokenUsage;
+ }
+ /**
+ * Gets the provider metadata.
+ *
+ * @since 0.1.0
+ *
+ * @return ProviderMetadata The provider metadata.
+ */
+ public function getProviderMetadata(): ProviderMetadata
+ {
+ return $this->providerMetadata;
+ }
+ /**
+ * Gets the model metadata.
+ *
+ * @since 0.1.0
+ *
+ * @return ModelMetadata The model metadata.
+ */
+ public function getModelMetadata(): ModelMetadata
+ {
+ return $this->modelMetadata;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public function getAdditionalData(): array
+ {
+ return $this->additionalData;
+ }
+ /**
+ * Gets the total number of candidates.
+ *
+ * @since 0.1.0
+ *
+ * @return int The total number of candidates.
+ */
+ public function getCandidateCount(): int
+ {
+ return count($this->candidates);
+ }
+ /**
+ * Checks if the result has multiple candidates.
+ *
+ * @since 0.1.0
+ *
+ * @return bool True if there are multiple candidates, false otherwise.
+ */
+ public function hasMultipleCandidates(): bool
+ {
+ return $this->getCandidateCount() > 1;
+ }
+ /**
+ * Converts the first candidate to text.
+ *
+ * Only text from the content channel is considered. Text within model thought or reasoning is ignored.
+ *
+ * @since 0.1.0
+ *
+ * @return string The text content.
+ * @throws RuntimeException If no text content.
+ */
+ public function toText(): string
+ {
+ $message = $this->candidates[0]->getMessage();
+ foreach ($message->getParts() as $part) {
+ $channel = $part->getChannel();
+ $text = $part->getText();
+ if ($channel->isContent() && $text !== null) {
+ return $text;
+ }
+ }
+ throw new RuntimeException('No text content found in first candidate');
+ }
+ /**
+ * Converts the first candidate to a file.
+ *
+ * Only files from the content channel are considered. Files within model thought or reasoning are ignored.
+ *
+ * @since 0.1.0
+ *
+ * @return File The file.
+ * @throws RuntimeException If no file content.
+ */
+ public function toFile(): File
+ {
+ $message = $this->candidates[0]->getMessage();
+ foreach ($message->getParts() as $part) {
+ $channel = $part->getChannel();
+ $file = $part->getFile();
+ if ($channel->isContent() && $file !== null) {
+ return $file;
+ }
+ }
+ throw new RuntimeException('No file content found in first candidate');
+ }
+ /**
+ * Converts the first candidate to an image file.
+ *
+ * @since 0.1.0
+ *
+ * @return File The image file.
+ * @throws RuntimeException If no image content.
+ */
+ public function toImageFile(): File
+ {
+ $file = $this->toFile();
+ if (!$file->isImage()) {
+ throw new RuntimeException(sprintf('File is not an image. MIME type: %s', $file->getMimeType()));
+ }
+ return $file;
+ }
+ /**
+ * Converts the first candidate to an audio file.
+ *
+ * @since 0.1.0
+ *
+ * @return File The audio file.
+ * @throws RuntimeException If no audio content.
+ */
+ public function toAudioFile(): File
+ {
+ $file = $this->toFile();
+ if (!$file->isAudio()) {
+ throw new RuntimeException(sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()));
+ }
+ return $file;
+ }
+ /**
+ * Converts the first candidate to a video file.
+ *
+ * @since 0.1.0
+ *
+ * @return File The video file.
+ * @throws RuntimeException If no video content.
+ */
+ public function toVideoFile(): File
+ {
+ $file = $this->toFile();
+ if (!$file->isVideo()) {
+ throw new RuntimeException(sprintf('File is not a video file. MIME type: %s', $file->getMimeType()));
+ }
+ return $file;
+ }
+ /**
+ * Converts the first candidate to a message.
+ *
+ * @since 0.1.0
+ *
+ * @return Message The message.
+ */
+ public function toMessage(): Message
+ {
+ return $this->candidates[0]->getMessage();
+ }
+ /**
+ * Converts all candidates to text.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of text content.
+ */
+ public function toTexts(): array
+ {
+ $texts = [];
+ foreach ($this->candidates as $candidate) {
+ $message = $candidate->getMessage();
+ foreach ($message->getParts() as $part) {
+ $channel = $part->getChannel();
+ $text = $part->getText();
+ if ($channel->isContent() && $text !== null) {
+ $texts[] = $text;
+ break;
+ }
+ }
+ }
+ return $texts;
+ }
+ /**
+ * Converts all candidates to files.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of files.
+ */
+ public function toFiles(): array
+ {
+ $files = [];
+ foreach ($this->candidates as $candidate) {
+ $message = $candidate->getMessage();
+ foreach ($message->getParts() as $part) {
+ $channel = $part->getChannel();
+ $file = $part->getFile();
+ if ($channel->isContent() && $file !== null) {
+ $files[] = $file;
+ break;
+ }
+ }
+ }
+ return $files;
+ }
+ /**
+ * Converts all candidates to image files.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of image files.
+ */
+ public function toImageFiles(): array
+ {
+ return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isImage()));
+ }
+ /**
+ * Converts all candidates to audio files.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of audio files.
+ */
+ public function toAudioFiles(): array
+ {
+ return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isAudio()));
+ }
+ /**
+ * Converts all candidates to video files.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of video files.
+ */
+ public function toVideoFiles(): array
+ {
+ return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isVideo()));
+ }
+ /**
+ * Converts all candidates to messages.
+ *
+ * @since 0.1.0
+ *
+ * @return list Array of messages.
+ */
+ public function toMessages(): array
+ {
+ return array_values(array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->getMessage(), $this->candidates));
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_CANDIDATES => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Candidate::getJsonSchema(), 'minItems' => 1, 'description' => 'The generated candidates.'], 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 data included in the API response.']], 'required' => [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return GenerativeAiResultArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ID => $this->id, self::KEY_CANDIDATES => array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->toArray(), $this->candidates), self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), self::KEY_ADDITIONAL_DATA => $this->additionalData];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]);
+ $candidates = array_map(fn(array $candidateData) => \WordPress\AiClient\Results\DTO\Candidate::fromArray($candidateData), $array[self::KEY_CANDIDATES]);
+ return new self($array[self::KEY_ID], $candidates, \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] ?? []);
+ }
+ /**
+ * Performs a deep clone of the result.
+ *
+ * This method ensures that all nested objects (candidates, token usage, metadata)
+ * are cloned to prevent modifications to the cloned result from affecting the original.
+ *
+ * @since 0.4.1
+ */
+ public function __clone()
+ {
+ $clonedCandidates = [];
+ foreach ($this->candidates as $candidate) {
+ $clonedCandidates[] = clone $candidate;
+ }
+ $this->candidates = $clonedCandidates;
+ $this->tokenUsage = clone $this->tokenUsage;
+ $this->providerMetadata = clone $this->providerMetadata;
+ $this->modelMetadata = clone $this->modelMetadata;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php
new file mode 100644
index 0000000000000..df3201c92f77d
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php
@@ -0,0 +1,118 @@
+
+ */
+class TokenUsage extends AbstractDataTransferObject
+{
+ public const KEY_PROMPT_TOKENS = 'promptTokens';
+ public const KEY_COMPLETION_TOKENS = 'completionTokens';
+ public const KEY_TOTAL_TOKENS = 'totalTokens';
+ /**
+ * @var int Number of tokens in the prompt.
+ */
+ private int $promptTokens;
+ /**
+ * @var int Number of tokens in the completion.
+ */
+ private int $completionTokens;
+ /**
+ * @var int Total number of tokens used.
+ */
+ private int $totalTokens;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param int $promptTokens Number of tokens in the prompt.
+ * @param int $completionTokens Number of tokens in the completion.
+ * @param int $totalTokens Total number of tokens used.
+ */
+ public function __construct(int $promptTokens, int $completionTokens, int $totalTokens)
+ {
+ $this->promptTokens = $promptTokens;
+ $this->completionTokens = $completionTokens;
+ $this->totalTokens = $totalTokens;
+ }
+ /**
+ * Gets the number of prompt tokens.
+ *
+ * @since 0.1.0
+ *
+ * @return int The prompt token count.
+ */
+ public function getPromptTokens(): int
+ {
+ return $this->promptTokens;
+ }
+ /**
+ * Gets the number of completion tokens.
+ *
+ * @since 0.1.0
+ *
+ * @return int The completion token count.
+ */
+ public function getCompletionTokens(): int
+ {
+ return $this->completionTokens;
+ }
+ /**
+ * Gets the total number of tokens.
+ *
+ * @since 0.1.0
+ *
+ * @return int The total token count.
+ */
+ public function getTotalTokens(): int
+ {
+ return $this->totalTokens;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return TokenUsageArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]);
+ return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS]);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php b/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php
new file mode 100644
index 0000000000000..b0c61b3fbe359
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php
@@ -0,0 +1,45 @@
+
+ */
+class FunctionCall extends AbstractDataTransferObject
+{
+ public const KEY_ID = 'id';
+ public const KEY_NAME = 'name';
+ public const KEY_ARGS = 'args';
+ /**
+ * @var string|null Unique identifier for this function call.
+ */
+ private ?string $id;
+ /**
+ * @var string|null The name of the function to call.
+ */
+ private ?string $name;
+ /**
+ * @var mixed The arguments to pass to the function.
+ */
+ private $args;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string|null $id Unique identifier for this function call.
+ * @param string|null $name The name of the function to call.
+ * @param mixed $args The arguments to pass to the function.
+ * @throws InvalidArgumentException If neither id nor name is provided.
+ */
+ public function __construct(?string $id = null, ?string $name = null, $args = null)
+ {
+ if ($id === null && $name === null) {
+ throw new InvalidArgumentException('At least one of id or name must be provided.');
+ }
+ $this->id = $id;
+ $this->name = $name;
+ $this->args = $args;
+ }
+ /**
+ * Gets the function call ID.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The function call ID.
+ */
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+ /**
+ * Gets the function name.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The function name.
+ */
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the function arguments.
+ *
+ * @since 0.1.0
+ *
+ * @return mixed The function arguments.
+ */
+ public function getArgs()
+ {
+ return $this->args;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this function call.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function to call.'], self::KEY_ARGS => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The arguments to pass to the function.']], 'oneOf' => [['required' => [self::KEY_ID]], ['required' => [self::KEY_NAME]]]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return FunctionCallArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [];
+ if ($this->id !== null) {
+ $data[self::KEY_ID] = $this->id;
+ }
+ if ($this->name !== null) {
+ $data[self::KEY_NAME] = $this->name;
+ }
+ if ($this->args !== null) {
+ $data[self::KEY_ARGS] = $this->args;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_ARGS] ?? null);
+ }
+}
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
new file mode 100644
index 0000000000000..935459f44ec0a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php
@@ -0,0 +1,122 @@
+
+ * }
+ *
+ * @extends AbstractDataTransferObject
+ */
+class FunctionDeclaration extends AbstractDataTransferObject
+{
+ public const KEY_NAME = 'name';
+ public const KEY_DESCRIPTION = 'description';
+ public const KEY_PARAMETERS = 'parameters';
+ /**
+ * @var string The name of the function.
+ */
+ private string $name;
+ /**
+ * @var string A description of what the function does.
+ */
+ private string $description;
+ /**
+ * @var array|null The JSON schema for the function parameters.
+ */
+ private ?array $parameters;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @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.
+ */
+ public function __construct(string $name, string $description, ?array $parameters = null)
+ {
+ $this->name = $name;
+ $this->description = $description;
+ $this->parameters = $parameters;
+ }
+ /**
+ * Gets the function name.
+ *
+ * @since 0.1.0
+ *
+ * @return string The function name.
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the function description.
+ *
+ * @since 0.1.0
+ *
+ * @return string The function description.
+ */
+ public function getDescription(): string
+ {
+ return $this->description;
+ }
+ /**
+ * Gets the function parameters schema.
+ *
+ * @since 0.1.0
+ *
+ * @return array|null The parameters schema.
+ */
+ public function getParameters(): ?array
+ {
+ return $this->parameters;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ 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]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return FunctionDeclarationArrayShape
+ */
+ public function toArray(): array
+ {
+ $data = [self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description];
+ if ($this->parameters !== null) {
+ $data[self::KEY_PARAMETERS] = $this->parameters;
+ }
+ return $data;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ 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);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php
new file mode 100644
index 0000000000000..ced268261387c
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php
@@ -0,0 +1,119 @@
+
+ */
+class FunctionResponse extends AbstractDataTransferObject
+{
+ public const KEY_ID = 'id';
+ public const KEY_NAME = 'name';
+ public const KEY_RESPONSE = 'response';
+ /**
+ * @var string The ID of the function call this is responding to.
+ */
+ private string $id;
+ /**
+ * @var string The name of the function that was called.
+ */
+ private string $name;
+ /**
+ * @var mixed The response data from the function.
+ */
+ private $response;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string $id The ID of the function call this is responding to.
+ * @param string $name The name of the function that was called.
+ * @param mixed $response The response data from the function.
+ */
+ public function __construct(string $id, string $name, $response)
+ {
+ $this->id = $id;
+ $this->name = $name;
+ $this->response = $response;
+ }
+ /**
+ * Gets the function call ID.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The function call ID.
+ */
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+ /**
+ * Gets the function name.
+ *
+ * @since 0.1.0
+ *
+ * @return string|null The function name.
+ */
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+ /**
+ * Gets the function response.
+ *
+ * @since 0.1.0
+ *
+ * @return mixed The response data.
+ */
+ public function getResponse()
+ {
+ return $this->response;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The ID of the function call this is responding to.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function that was called.'], self::KEY_RESPONSE => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The response data from the function.']], 'oneOf' => [['required' => [self::KEY_RESPONSE, self::KEY_ID]], ['required' => [self::KEY_RESPONSE, self::KEY_NAME]]]];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return FunctionResponseArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_RESPONSE => $this->response];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ static::validateFromArrayData($array, [self::KEY_RESPONSE]);
+ // Validate that at least one of id or name is provided
+ if (!array_key_exists(self::KEY_ID, $array) && !array_key_exists(self::KEY_NAME, $array)) {
+ throw new InvalidArgumentException('At least one of id or name must be provided.');
+ }
+ return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_RESPONSE]);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php b/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php
new file mode 100644
index 0000000000000..3ce1c62d37099
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php
@@ -0,0 +1,95 @@
+
+ */
+class WebSearch extends AbstractDataTransferObject
+{
+ public const KEY_ALLOWED_DOMAINS = 'allowedDomains';
+ public const KEY_DISALLOWED_DOMAINS = 'disallowedDomains';
+ /**
+ * @var string[] List of domains that are allowed for web search.
+ */
+ private array $allowedDomains;
+ /**
+ * @var string[] List of domains that are disallowed for web search.
+ */
+ private array $disallowedDomains;
+ /**
+ * Constructor.
+ *
+ * @since 0.1.0
+ *
+ * @param string[] $allowedDomains List of domains that are allowed for web search.
+ * @param string[] $disallowedDomains List of domains that are disallowed for web search.
+ */
+ public function __construct(array $allowedDomains = [], array $disallowedDomains = [])
+ {
+ $this->allowedDomains = $allowedDomains;
+ $this->disallowedDomains = $disallowedDomains;
+ }
+ /**
+ * Gets the allowed domains.
+ *
+ * @since 0.1.0
+ *
+ * @return string[] The allowed domains.
+ */
+ public function getAllowedDomains(): array
+ {
+ return $this->allowedDomains;
+ }
+ /**
+ * Gets the disallowed domains.
+ *
+ * @since 0.1.0
+ *
+ * @return string[] The disallowed domains.
+ */
+ public function getDisallowedDomains(): array
+ {
+ return $this->disallowedDomains;
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function getJsonSchema(): array
+ {
+ return ['type' => 'object', 'properties' => [self::KEY_ALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are allowed for web search.'], self::KEY_DISALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are disallowed for web search.']], 'required' => []];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ *
+ * @return WebSearchArrayShape
+ */
+ public function toArray(): array
+ {
+ return [self::KEY_ALLOWED_DOMAINS => $this->allowedDomains, self::KEY_DISALLOWED_DOMAINS => $this->disallowedDomains];
+ }
+ /**
+ * {@inheritDoc}
+ *
+ * @since 0.1.0
+ */
+ public static function fromArray(array $array): self
+ {
+ return new self($array[self::KEY_ALLOWED_DOMAINS] ?? [], $array[self::KEY_DISALLOWED_DOMAINS] ?? []);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/src/polyfills.php b/src/wp-includes/php-ai-client/src/polyfills.php
new file mode 100644
index 0000000000000..20bb0fede1c0b
--- /dev/null
+++ b/src/wp-includes/php-ai-client/src/polyfills.php
@@ -0,0 +1,91 @@
+ $array The array to check.
+ * @return bool True if the array is a list, false otherwise.
+ */
+ function array_is_list(array $array): bool
+ {
+ if ($array === []) {
+ return \true;
+ }
+ $expectedKey = 0;
+ foreach (\array_keys($array) as $key) {
+ if ($key !== $expectedKey) {
+ return \false;
+ }
+ $expectedKey++;
+ }
+ return \true;
+ }
+}
+if (!\function_exists('str_starts_with') && !\function_exists('WordPress\AiClientDependencies\str_starts_with')) {
+ /**
+ * Checks if a string starts with a given substring.
+ *
+ * @since 0.1.0
+ *
+ * @param string $haystack The string to search in.
+ * @param string $needle The substring to search for.
+ * @return bool True if $haystack starts with $needle, false otherwise.
+ */
+ function str_starts_with(string $haystack, string $needle): bool
+ {
+ if ('' === $needle) {
+ return \true;
+ }
+ return 0 === \strpos($haystack, $needle);
+ }
+}
+if (!\function_exists('str_contains') && !\function_exists('WordPress\AiClientDependencies\str_contains')) {
+ /**
+ * Checks if a string contains a given substring.
+ *
+ * @since 0.1.0
+ *
+ * @param string $haystack The string to search in.
+ * @param string $needle The substring to search for.
+ * @return bool True if $haystack contains $needle, false otherwise.
+ */
+ function str_contains(string $haystack, string $needle): bool
+ {
+ if ('' === $needle) {
+ return \true;
+ }
+ return \false !== \strpos($haystack, $needle);
+ }
+}
+if (!\function_exists('str_ends_with') && !\function_exists('WordPress\AiClientDependencies\str_ends_with')) {
+ /**
+ * Checks if a string ends with a given substring.
+ *
+ * @since 0.1.0
+ *
+ * @param string $haystack The string to search in.
+ * @param string $needle The substring to search for.
+ * @return bool True if $haystack ends with $needle, false otherwise.
+ */
+ function str_ends_with(string $haystack, string $needle): bool
+ {
+ if ('' === $haystack) {
+ return '' === $needle;
+ }
+ $len = \strlen($needle);
+ return \substr($haystack, -$len, $len) === $needle;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php
new file mode 100644
index 0000000000000..50c622f8bb6ba
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php
@@ -0,0 +1,219 @@
+
+ * @author Márk Sági-Kazár
+ * @author Tobias Nyholm
+ */
+abstract class ClassDiscovery
+{
+ /**
+ * A list of strategies to find classes.
+ *
+ * @var DiscoveryStrategy[]
+ */
+ private static $strategies = [Strategy\GeneratedDiscoveryStrategy::class, Strategy\CommonClassesStrategy::class, Strategy\CommonPsr17ClassesStrategy::class, Strategy\PuliBetaStrategy::class];
+ private static $deprecatedStrategies = [Strategy\PuliBetaStrategy::class => \true];
+ /**
+ * Discovery cache to make the second time we use discovery faster.
+ *
+ * @var array
+ */
+ private static $cache = [];
+ /**
+ * Finds a class.
+ *
+ * @param string $type
+ *
+ * @return string|\Closure
+ *
+ * @throws DiscoveryFailedException
+ */
+ protected static function findOneByType($type)
+ {
+ // Look in the cache
+ if (null !== $class = self::getFromCache($type)) {
+ return $class;
+ }
+ static $skipStrategy;
+ $skipStrategy ?? $skipStrategy = self::safeClassExists(Strategy\GeneratedDiscoveryStrategy::class) ? \false : Strategy\GeneratedDiscoveryStrategy::class;
+ $exceptions = [];
+ foreach (self::$strategies as $strategy) {
+ if ($skipStrategy === $strategy) {
+ continue;
+ }
+ try {
+ $candidates = $strategy::getCandidates($type);
+ } catch (StrategyUnavailableException $e) {
+ if (!isset(self::$deprecatedStrategies[$strategy])) {
+ $exceptions[] = $e;
+ }
+ continue;
+ }
+ foreach ($candidates as $candidate) {
+ if (isset($candidate['condition'])) {
+ if (!self::evaluateCondition($candidate['condition'])) {
+ continue;
+ }
+ }
+ // save the result for later use
+ self::storeInCache($type, $candidate);
+ return $candidate['class'];
+ }
+ $exceptions[] = new NoCandidateFoundException($strategy, $candidates);
+ }
+ throw DiscoveryFailedException::create($exceptions);
+ }
+ /**
+ * Get a value from cache.
+ *
+ * @param string $type
+ *
+ * @return string|null
+ */
+ private static function getFromCache($type)
+ {
+ if (!isset(self::$cache[$type])) {
+ return;
+ }
+ $candidate = self::$cache[$type];
+ if (isset($candidate['condition'])) {
+ if (!self::evaluateCondition($candidate['condition'])) {
+ return;
+ }
+ }
+ return $candidate['class'];
+ }
+ /**
+ * Store a value in cache.
+ *
+ * @param string $type
+ * @param string $class
+ */
+ private static function storeInCache($type, $class)
+ {
+ self::$cache[$type] = $class;
+ }
+ /**
+ * Set new strategies and clear the cache.
+ *
+ * @param string[] $strategies list of fully qualified class names that implement DiscoveryStrategy
+ */
+ public static function setStrategies(array $strategies)
+ {
+ self::$strategies = $strategies;
+ self::clearCache();
+ }
+ /**
+ * Returns the currently configured discovery strategies as fully qualified class names.
+ *
+ * @return string[]
+ */
+ public static function getStrategies(): iterable
+ {
+ return self::$strategies;
+ }
+ /**
+ * Append a strategy at the end of the strategy queue.
+ *
+ * @param string $strategy Fully qualified class name of a DiscoveryStrategy
+ */
+ public static function appendStrategy($strategy)
+ {
+ self::$strategies[] = $strategy;
+ self::clearCache();
+ }
+ /**
+ * Prepend a strategy at the beginning of the strategy queue.
+ *
+ * @param string $strategy Fully qualified class name to a DiscoveryStrategy
+ */
+ public static function prependStrategy($strategy)
+ {
+ array_unshift(self::$strategies, $strategy);
+ self::clearCache();
+ }
+ public static function clearCache()
+ {
+ self::$cache = [];
+ }
+ /**
+ * Evaluates conditions to boolean.
+ *
+ * @return bool
+ */
+ protected static function evaluateCondition($condition)
+ {
+ if (is_string($condition)) {
+ // Should be extended for functions, extensions???
+ return self::safeClassExists($condition);
+ }
+ if (is_callable($condition)) {
+ return (bool) $condition();
+ }
+ if (is_bool($condition)) {
+ return $condition;
+ }
+ if (is_array($condition)) {
+ foreach ($condition as $c) {
+ if (\false === static::evaluateCondition($c)) {
+ // Immediately stop execution if the condition is false
+ return \false;
+ }
+ }
+ return \true;
+ }
+ return \false;
+ }
+ /**
+ * Get an instance of the $class.
+ *
+ * @param string|\Closure $class a FQCN of a class or a closure that instantiate the class
+ *
+ * @return object
+ *
+ * @throws ClassInstantiationFailedException
+ */
+ protected static function instantiateClass($class)
+ {
+ try {
+ if (is_string($class)) {
+ return new $class();
+ }
+ if (is_callable($class)) {
+ return $class();
+ }
+ } catch (\Exception $e) {
+ throw new ClassInstantiationFailedException('Unexpected exception when instantiating class.', 0, $e);
+ }
+ throw new ClassInstantiationFailedException('Could not instantiate class because parameter is neither a callable nor a string');
+ }
+ /**
+ * We need a "safe" version of PHP's "class_exists" because Magento has a bug
+ * (or they call it a "feature"). Magento is throwing an exception if you do class_exists()
+ * on a class that ends with "Factory" and if that file does not exits.
+ *
+ * This function catches all potential exceptions and makes sure to always return a boolean.
+ *
+ * @param string $class
+ *
+ * @return bool
+ */
+ public static function safeClassExists($class)
+ {
+ try {
+ return class_exists($class) || interface_exists($class);
+ } catch (\Exception $e) {
+ return \false;
+ }
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php
new file mode 100644
index 0000000000000..183ac1dbf1f04
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php
@@ -0,0 +1,12 @@
+
+ */
+interface Exception extends \Throwable
+{
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php
new file mode 100644
index 0000000000000..0dc05d7a5d4d7
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php
@@ -0,0 +1,13 @@
+
+ */
+final class ClassInstantiationFailedException extends \RuntimeException implements Exception
+{
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php
new file mode 100644
index 0000000000000..f765acddd3fe9
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php
@@ -0,0 +1,45 @@
+
+ */
+final class DiscoveryFailedException extends \Exception implements Exception
+{
+ /**
+ * @var \Exception[]
+ */
+ private $exceptions;
+ /**
+ * @param string $message
+ * @param \Exception[] $exceptions
+ */
+ public function __construct($message, array $exceptions = [])
+ {
+ $this->exceptions = $exceptions;
+ parent::__construct($message);
+ }
+ /**
+ * @param \Exception[] $exceptions
+ */
+ public static function create($exceptions)
+ {
+ $message = 'Could not find resource using any discovery strategy. Find more information at http://docs.php-http.org/en/latest/discovery.html#common-errors';
+ foreach ($exceptions as $e) {
+ $message .= "\n - " . $e->getMessage();
+ }
+ $message .= "\n\n";
+ return new self($message, $exceptions);
+ }
+ /**
+ * @return \Exception[]
+ */
+ public function getExceptions()
+ {
+ return $this->exceptions;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php
new file mode 100644
index 0000000000000..621d3f708e76e
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php
@@ -0,0 +1,34 @@
+
+ */
+final class NoCandidateFoundException extends \Exception implements Exception
+{
+ /**
+ * @param string $strategy
+ */
+ public function __construct($strategy, array $candidates)
+ {
+ $classes = array_map(function ($a) {
+ return $a['class'];
+ }, $candidates);
+ $message = sprintf('No valid candidate found using strategy "%s". We tested the following candidates: %s.', $strategy, implode(', ', array_map([$this, 'stringify'], $classes)));
+ parent::__construct($message);
+ }
+ private function stringify($mixed)
+ {
+ if (is_string($mixed)) {
+ return $mixed;
+ }
+ if (is_array($mixed) && 2 === count($mixed)) {
+ return sprintf('%s::%s', $this->stringify($mixed[0]), $mixed[1]);
+ }
+ return is_object($mixed) ? get_class($mixed) : gettype($mixed);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php
new file mode 100644
index 0000000000000..3d93ddf48aaaa
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php
@@ -0,0 +1,16 @@
+
+ */
+/* final */
+class NotFoundException extends \RuntimeException implements Exception
+{
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php
new file mode 100644
index 0000000000000..0ed157f7a0bbf
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php
@@ -0,0 +1,12 @@
+
+ */
+final class PuliUnavailableException extends StrategyUnavailableException
+{
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php
new file mode 100644
index 0000000000000..4887391eacd6c
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php
@@ -0,0 +1,14 @@
+
+ */
+class StrategyUnavailableException extends \RuntimeException implements Exception
+{
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php
new file mode 100644
index 0000000000000..5e22ab1dd03c0
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php
@@ -0,0 +1,119 @@
+
+ */
+final class Psr17FactoryDiscovery extends ClassDiscovery
+{
+ private static function createException($type, Exception $e)
+ {
+ return new RealNotFoundException('No PSR-17 ' . $type . ' found. Install a package from this list: https://packagist.org/providers/psr/http-factory-implementation', 0, $e);
+ }
+ /**
+ * @return RequestFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findRequestFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(RequestFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('request factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return ResponseFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findResponseFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(ResponseFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('response factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return ServerRequestFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findServerRequestFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(ServerRequestFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('server request factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return StreamFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findStreamFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(StreamFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('stream factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return UploadedFileFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findUploadedFileFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(UploadedFileFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('uploaded file factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return UriFactoryInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function findUriFactory()
+ {
+ try {
+ $messageFactory = static::findOneByType(UriFactoryInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw self::createException('url factory', $e);
+ }
+ return static::instantiateClass($messageFactory);
+ }
+ /**
+ * @return UriFactoryInterface
+ *
+ * @throws RealNotFoundException
+ *
+ * @deprecated This will be removed in 2.0. Consider using the findUriFactory() method.
+ */
+ public static function findUrlFactory()
+ {
+ return static::findUriFactory();
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php
new file mode 100644
index 0000000000000..ceca0e4a515b5
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php
@@ -0,0 +1,31 @@
+
+ */
+final class Psr18ClientDiscovery extends ClassDiscovery
+{
+ /**
+ * Finds a PSR-18 HTTP Client.
+ *
+ * @return ClientInterface
+ *
+ * @throws RealNotFoundException
+ */
+ public static function find()
+ {
+ try {
+ $client = static::findOneByType(ClientInterface::class);
+ } catch (DiscoveryFailedException $e) {
+ throw new RealNotFoundException('No PSR-18 clients found. Make sure to install a package providing "psr/http-client-implementation". Example: "php-http/guzzle7-adapter".', 0, $e);
+ }
+ return static::instantiateClass($client);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php
new file mode 100644
index 0000000000000..e9c65c8220e93
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php
@@ -0,0 +1,116 @@
+
+ *
+ * Don't miss updating src/Composer/Plugin.php when adding a new supported class.
+ */
+final class CommonClassesStrategy implements DiscoveryStrategy
+{
+ /**
+ * @var array
+ */
+ private static $classes = [MessageFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleMessageFactory::class, 'condition' => [GuzzleRequest::class, GuzzleMessageFactory::class]], ['class' => DiactorosMessageFactory::class, 'condition' => [DiactorosRequest::class, DiactorosMessageFactory::class]], ['class' => SlimMessageFactory::class, 'condition' => [SlimRequest::class, SlimMessageFactory::class]]], StreamFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleStreamFactory::class, 'condition' => [GuzzleRequest::class, GuzzleStreamFactory::class]], ['class' => DiactorosStreamFactory::class, 'condition' => [DiactorosRequest::class, DiactorosStreamFactory::class]], ['class' => SlimStreamFactory::class, 'condition' => [SlimRequest::class, SlimStreamFactory::class]]], UriFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleUriFactory::class, 'condition' => [GuzzleRequest::class, GuzzleUriFactory::class]], ['class' => DiactorosUriFactory::class, 'condition' => [DiactorosRequest::class, DiactorosUriFactory::class]], ['class' => SlimUriFactory::class, 'condition' => [SlimRequest::class, SlimUriFactory::class]]], HttpAsyncClient::class => [['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, Promise::class, [self::class, 'isPsr17FactoryInstalled']]], ['class' => Guzzle7::class, 'condition' => Guzzle7::class], ['class' => Guzzle6::class, 'condition' => Guzzle6::class], ['class' => Curl::class, 'condition' => Curl::class], ['class' => React::class, 'condition' => React::class]], HttpClient::class => [['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, [self::class, 'isPsr17FactoryInstalled'], [self::class, 'isSymfonyImplementingHttpClient']]], ['class' => Guzzle7::class, 'condition' => Guzzle7::class], ['class' => Guzzle6::class, 'condition' => Guzzle6::class], ['class' => Guzzle5::class, 'condition' => Guzzle5::class], ['class' => Curl::class, 'condition' => Curl::class], ['class' => Socket::class, 'condition' => Socket::class], ['class' => Buzz::class, 'condition' => Buzz::class], ['class' => React::class, 'condition' => React::class], ['class' => Cake::class, 'condition' => Cake::class], ['class' => Artax::class, 'condition' => Artax::class], ['class' => [self::class, 'buzzInstantiate'], 'condition' => [\WordPress\AiClientDependencies\Buzz\Client\FileGetContents::class, \WordPress\AiClientDependencies\Buzz\Message\ResponseBuilder::class]]], Psr18Client::class => [['class' => [self::class, 'symfonyPsr18Instantiate'], 'condition' => [SymfonyPsr18::class, Psr17RequestFactory::class]], ['class' => GuzzleHttp::class, 'condition' => [self::class, 'isGuzzleImplementingPsr18']], ['class' => [self::class, 'buzzInstantiate'], 'condition' => [\WordPress\AiClientDependencies\Buzz\Client\FileGetContents::class, \WordPress\AiClientDependencies\Buzz\Message\ResponseBuilder::class]]]];
+ public static function getCandidates($type)
+ {
+ if (Psr18Client::class === $type) {
+ return self::getPsr18Candidates();
+ }
+ return self::$classes[$type] ?? [];
+ }
+ /**
+ * @return array The return value is always an array with zero or more elements. Each
+ * element is an array with two keys ['class' => string, 'condition' => mixed].
+ */
+ private static function getPsr18Candidates()
+ {
+ $candidates = self::$classes[Psr18Client::class];
+ // HTTPlug 2.0 clients implements PSR18Client too.
+ foreach (self::$classes[HttpClient::class] as $c) {
+ if (!is_string($c['class'])) {
+ continue;
+ }
+ try {
+ if (ClassDiscovery::safeClassExists($c['class']) && is_subclass_of($c['class'], Psr18Client::class)) {
+ $candidates[] = $c;
+ }
+ } catch (\Throwable $e) {
+ trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-18 Client is available', get_class($e), $e->getMessage()), \E_USER_WARNING);
+ }
+ }
+ return $candidates;
+ }
+ public static function buzzInstantiate()
+ {
+ return new \WordPress\AiClientDependencies\Buzz\Client\FileGetContents(Psr17FactoryDiscovery::findResponseFactory());
+ }
+ public static function symfonyPsr18Instantiate()
+ {
+ return new SymfonyPsr18(null, Psr17FactoryDiscovery::findResponseFactory(), Psr17FactoryDiscovery::findStreamFactory());
+ }
+ public static function isGuzzleImplementingPsr18()
+ {
+ return defined('GuzzleHttp\ClientInterface::MAJOR_VERSION');
+ }
+ public static function isSymfonyImplementingHttpClient()
+ {
+ return is_subclass_of(SymfonyHttplug::class, HttpClient::class);
+ }
+ /**
+ * Can be used as a condition.
+ *
+ * @return bool
+ */
+ public static function isPsr17FactoryInstalled()
+ {
+ try {
+ Psr17FactoryDiscovery::findResponseFactory();
+ } catch (NotFoundException $e) {
+ return \false;
+ } catch (\Throwable $e) {
+ trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-17 ResponseFactory is available', get_class($e), $e->getMessage()), \E_USER_WARNING);
+ return \false;
+ }
+ return \true;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php
new file mode 100644
index 0000000000000..7a310542c13c4
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php
@@ -0,0 +1,34 @@
+
+ *
+ * Don't miss updating src/Composer/Plugin.php when adding a new supported class.
+ */
+final class CommonPsr17ClassesStrategy implements DiscoveryStrategy
+{
+ /**
+ * @var array
+ */
+ private static $classes = [RequestFactoryInterface::class => ['Phalcon\Http\Message\RequestFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\RequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\RequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\RequestFactory', 'Laminas\Diactoros\RequestFactory', 'Slim\Psr7\Factory\RequestFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\RequestFactory'], ResponseFactoryInterface::class => ['Phalcon\Http\Message\ResponseFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\ResponseFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\ResponseFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\ResponseFactory', 'Laminas\Diactoros\ResponseFactory', 'Slim\Psr7\Factory\ResponseFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\ResponseFactory'], ServerRequestFactoryInterface::class => ['Phalcon\Http\Message\ServerRequestFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\ServerRequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\ServerRequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\ServerRequestFactory', 'Laminas\Diactoros\ServerRequestFactory', 'Slim\Psr7\Factory\ServerRequestFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\ServerRequestFactory'], StreamFactoryInterface::class => ['Phalcon\Http\Message\StreamFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\StreamFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\StreamFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\StreamFactory', 'Laminas\Diactoros\StreamFactory', 'Slim\Psr7\Factory\StreamFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\StreamFactory'], UploadedFileFactoryInterface::class => ['Phalcon\Http\Message\UploadedFileFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\UploadedFileFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\UploadedFileFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\UploadedFileFactory', 'Laminas\Diactoros\UploadedFileFactory', 'Slim\Psr7\Factory\UploadedFileFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\UploadedFileFactory'], UriFactoryInterface::class => ['Phalcon\Http\Message\UriFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\UriFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\UriFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\UriFactory', 'Laminas\Diactoros\UriFactory', 'Slim\Psr7\Factory\UriFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\UriFactory']];
+ public static function getCandidates($type)
+ {
+ $candidates = [];
+ if (isset(self::$classes[$type])) {
+ foreach (self::$classes[$type] as $class) {
+ $candidates[] = ['class' => $class, 'condition' => [$class]];
+ }
+ }
+ return $candidates;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php
new file mode 100644
index 0000000000000..d7f782db42df7
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php
@@ -0,0 +1,22 @@
+
+ */
+interface DiscoveryStrategy
+{
+ /**
+ * Find a resource of a specific type.
+ *
+ * @param string $type
+ *
+ * @return array The return value is always an array with zero or more elements. Each
+ * element is an array with two keys ['class' => string, 'condition' => mixed].
+ *
+ * @throws StrategyUnavailableException if we cannot use this strategy
+ */
+ public static function getCandidates($type);
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php
new file mode 100644
index 0000000000000..bdcfc82344514
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php
@@ -0,0 +1,77 @@
+
+ * @author Márk Sági-Kazár
+ */
+class PuliBetaStrategy implements DiscoveryStrategy
+{
+ /**
+ * @var GeneratedPuliFactory
+ */
+ protected static $puliFactory;
+ /**
+ * @var Discovery
+ */
+ protected static $puliDiscovery;
+ /**
+ * @return GeneratedPuliFactory
+ *
+ * @throws PuliUnavailableException
+ */
+ private static function getPuliFactory()
+ {
+ if (null === self::$puliFactory) {
+ if (!defined('PULI_FACTORY_CLASS')) {
+ throw new PuliUnavailableException('Puli Factory is not available');
+ }
+ $puliFactoryClass = PULI_FACTORY_CLASS;
+ if (!ClassDiscovery::safeClassExists($puliFactoryClass)) {
+ throw new PuliUnavailableException('Puli Factory class does not exist');
+ }
+ self::$puliFactory = new $puliFactoryClass();
+ }
+ return self::$puliFactory;
+ }
+ /**
+ * Returns the Puli discovery layer.
+ *
+ * @return Discovery
+ *
+ * @throws PuliUnavailableException
+ */
+ private static function getPuliDiscovery()
+ {
+ if (!isset(self::$puliDiscovery)) {
+ $factory = self::getPuliFactory();
+ $repository = $factory->createRepository();
+ self::$puliDiscovery = $factory->createDiscovery($repository);
+ }
+ return self::$puliDiscovery;
+ }
+ public static function getCandidates($type)
+ {
+ $returnData = [];
+ $bindings = self::getPuliDiscovery()->findBindings($type);
+ foreach ($bindings as $binding) {
+ $condition = \true;
+ if ($binding->hasParameterValue('depends')) {
+ $condition = $binding->getParameterValue('depends');
+ }
+ $returnData[] = ['class' => $binding->getClassName(), 'condition' => $condition];
+ }
+ return $returnData;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php
new file mode 100644
index 0000000000000..4b85d3d500600
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php
@@ -0,0 +1,21 @@
+getHeaders() as $name => $values) {
+ * echo $name . ": " . implode(", ", $values);
+ * }
+ *
+ * // Emit headers iteratively:
+ * foreach ($message->getHeaders() as $name => $values) {
+ * foreach ($values as $value) {
+ * header(sprintf('%s: %s', $name, $value), false);
+ * }
+ * }
+ *
+ * While header names are not case-sensitive, getHeaders() will preserve the
+ * exact case in which headers were originally specified.
+ *
+ * @return string[][] Returns an associative array of the message's headers. Each
+ * key MUST be a header name, and each value MUST be an array of strings
+ * for that header.
+ */
+ public function getHeaders(): array;
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return bool Returns true if any header names match the given header
+ * name using a case-insensitive string comparison. Returns false if
+ * no matching header name is found in the message.
+ */
+ public function hasHeader(string $name): bool;
+ /**
+ * Retrieves a message header value by the given case-insensitive name.
+ *
+ * This method returns an array of all the header values of the given
+ * case-insensitive header name.
+ *
+ * If the header does not appear in the message, this method MUST return an
+ * empty array.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string[] An array of string values as provided for the given
+ * header. If the header does not appear in the message, this method MUST
+ * return an empty array.
+ */
+ public function getHeader(string $name): array;
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * This method returns all of the header values of the given
+ * case-insensitive header name as a string concatenated together using
+ * a comma.
+ *
+ * NOTE: Not all header values may be appropriately represented using
+ * comma concatenation. For such headers, use getHeader() instead
+ * and supply your own delimiter when concatenating.
+ *
+ * If the header does not appear in the message, this method MUST return
+ * an empty string.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string A string of values as provided for the given header
+ * concatenated together using a comma. If the header does not appear in
+ * the message, this method MUST return an empty string.
+ */
+ public function getHeaderLine(string $name): string;
+ /**
+ * Return an instance with the provided value replacing the specified header.
+ *
+ * While header names are case-insensitive, the casing of the header will
+ * be preserved by this function, and returned from getHeaders().
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new and/or updated header and value.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ * @throws \InvalidArgumentException for invalid header names or values.
+ */
+ public function withHeader(string $name, $value): MessageInterface;
+ /**
+ * Return an instance with the specified header appended with the given value.
+ *
+ * Existing values for the specified header will be maintained. The new
+ * value(s) will be appended to the existing list. If the header did not
+ * exist previously, it will be added.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new header and/or value.
+ *
+ * @param string $name Case-insensitive header field name to add.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ * @throws \InvalidArgumentException for invalid header names or values.
+ */
+ public function withAddedHeader(string $name, $value): MessageInterface;
+ /**
+ * Return an instance without the specified header.
+ *
+ * Header resolution MUST be done without case-sensitivity.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that removes
+ * the named header.
+ *
+ * @param string $name Case-insensitive header field name to remove.
+ * @return static
+ */
+ public function withoutHeader(string $name): MessageInterface;
+ /**
+ * Gets the body of the message.
+ *
+ * @return StreamInterface Returns the body as a stream.
+ */
+ public function getBody(): StreamInterface;
+ /**
+ * Return an instance with the specified message body.
+ *
+ * The body MUST be a StreamInterface object.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return a new instance that has the
+ * new body stream.
+ *
+ * @param StreamInterface $body Body.
+ * @return static
+ * @throws \InvalidArgumentException When the body is not valid.
+ */
+ public function withBody(StreamInterface $body): MessageInterface;
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php
new file mode 100644
index 0000000000000..45d1c5c5bf491
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php
@@ -0,0 +1,18 @@
+
+ * [user-info@]host[:port]
+ *
+ *
+ * If the port component is not set or is the standard port for the current
+ * scheme, it SHOULD NOT be included.
+ *
+ * @see https://tools.ietf.org/html/rfc3986#section-3.2
+ * @return string The URI authority, in "[user-info@]host[:port]" format.
+ */
+ public function getAuthority(): string;
+ /**
+ * Retrieve the user information component of the URI.
+ *
+ * If no user information is present, this method MUST return an empty
+ * string.
+ *
+ * If a user is present in the URI, this will return that value;
+ * additionally, if the password is also present, it will be appended to the
+ * user value, with a colon (":") separating the values.
+ *
+ * The trailing "@" character is not part of the user information and MUST
+ * NOT be added.
+ *
+ * @return string The URI user information, in "username[:password]" format.
+ */
+ public function getUserInfo(): string;
+ /**
+ * Retrieve the host component of the URI.
+ *
+ * If no host is present, this method MUST return an empty string.
+ *
+ * The value returned MUST be normalized to lowercase, per RFC 3986
+ * Section 3.2.2.
+ *
+ * @see http://tools.ietf.org/html/rfc3986#section-3.2.2
+ * @return string The URI host.
+ */
+ public function getHost(): string;
+ /**
+ * Retrieve the port component of the URI.
+ *
+ * If a port is present, and it is non-standard for the current scheme,
+ * this method MUST return it as an integer. If the port is the standard port
+ * used with the current scheme, this method SHOULD return null.
+ *
+ * If no port is present, and no scheme is present, this method MUST return
+ * a null value.
+ *
+ * If no port is present, but a scheme is present, this method MAY return
+ * the standard port for that scheme, but SHOULD return null.
+ *
+ * @return null|int The URI port.
+ */
+ public function getPort(): ?int;
+ /**
+ * Retrieve the path component of the URI.
+ *
+ * The path can either be empty or absolute (starting with a slash) or
+ * rootless (not starting with a slash). Implementations MUST support all
+ * three syntaxes.
+ *
+ * Normally, the empty path "" and absolute path "/" are considered equal as
+ * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically
+ * do this normalization because in contexts with a trimmed base path, e.g.
+ * the front controller, this difference becomes significant. It's the task
+ * of the user to handle both "" and "/".
+ *
+ * The value returned MUST be percent-encoded, but MUST NOT double-encode
+ * any characters. To determine what characters to encode, please refer to
+ * RFC 3986, Sections 2 and 3.3.
+ *
+ * As an example, if the value should include a slash ("/") not intended as
+ * delimiter between path segments, that value MUST be passed in encoded
+ * form (e.g., "%2F") to the instance.
+ *
+ * @see https://tools.ietf.org/html/rfc3986#section-2
+ * @see https://tools.ietf.org/html/rfc3986#section-3.3
+ * @return string The URI path.
+ */
+ public function getPath(): string;
+ /**
+ * Retrieve the query string of the URI.
+ *
+ * If no query string is present, this method MUST return an empty string.
+ *
+ * The leading "?" character is not part of the query and MUST NOT be
+ * added.
+ *
+ * The value returned MUST be percent-encoded, but MUST NOT double-encode
+ * any characters. To determine what characters to encode, please refer to
+ * RFC 3986, Sections 2 and 3.4.
+ *
+ * As an example, if a value in a key/value pair of the query string should
+ * include an ampersand ("&") not intended as a delimiter between values,
+ * that value MUST be passed in encoded form (e.g., "%26") to the instance.
+ *
+ * @see https://tools.ietf.org/html/rfc3986#section-2
+ * @see https://tools.ietf.org/html/rfc3986#section-3.4
+ * @return string The URI query string.
+ */
+ public function getQuery(): string;
+ /**
+ * Retrieve the fragment component of the URI.
+ *
+ * If no fragment is present, this method MUST return an empty string.
+ *
+ * The leading "#" character is not part of the fragment and MUST NOT be
+ * added.
+ *
+ * The value returned MUST be percent-encoded, but MUST NOT double-encode
+ * any characters. To determine what characters to encode, please refer to
+ * RFC 3986, Sections 2 and 3.5.
+ *
+ * @see https://tools.ietf.org/html/rfc3986#section-2
+ * @see https://tools.ietf.org/html/rfc3986#section-3.5
+ * @return string The URI fragment.
+ */
+ public function getFragment(): string;
+ /**
+ * Return an instance with the specified scheme.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified scheme.
+ *
+ * Implementations MUST support the schemes "http" and "https" case
+ * insensitively, and MAY accommodate other schemes if required.
+ *
+ * An empty scheme is equivalent to removing the scheme.
+ *
+ * @param string $scheme The scheme to use with the new instance.
+ * @return static A new instance with the specified scheme.
+ * @throws \InvalidArgumentException for invalid or unsupported schemes.
+ */
+ public function withScheme(string $scheme): UriInterface;
+ /**
+ * Return an instance with the specified user information.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified user information.
+ *
+ * Password is optional, but the user information MUST include the
+ * user; an empty string for the user is equivalent to removing user
+ * information.
+ *
+ * @param string $user The user name to use for authority.
+ * @param null|string $password The password associated with $user.
+ * @return static A new instance with the specified user information.
+ */
+ public function withUserInfo(string $user, ?string $password = null): UriInterface;
+ /**
+ * Return an instance with the specified host.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified host.
+ *
+ * An empty host value is equivalent to removing the host.
+ *
+ * @param string $host The hostname to use with the new instance.
+ * @return static A new instance with the specified host.
+ * @throws \InvalidArgumentException for invalid hostnames.
+ */
+ public function withHost(string $host): UriInterface;
+ /**
+ * Return an instance with the specified port.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified port.
+ *
+ * Implementations MUST raise an exception for ports outside the
+ * established TCP and UDP port ranges.
+ *
+ * A null value provided for the port is equivalent to removing the port
+ * information.
+ *
+ * @param null|int $port The port to use with the new instance; a null value
+ * removes the port information.
+ * @return static A new instance with the specified port.
+ * @throws \InvalidArgumentException for invalid ports.
+ */
+ public function withPort(?int $port): UriInterface;
+ /**
+ * Return an instance with the specified path.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified path.
+ *
+ * The path can either be empty or absolute (starting with a slash) or
+ * rootless (not starting with a slash). Implementations MUST support all
+ * three syntaxes.
+ *
+ * If the path is intended to be domain-relative rather than path relative then
+ * it must begin with a slash ("/"). Paths not starting with a slash ("/")
+ * are assumed to be relative to some base path known to the application or
+ * consumer.
+ *
+ * Users can provide both encoded and decoded path characters.
+ * Implementations ensure the correct encoding as outlined in getPath().
+ *
+ * @param string $path The path to use with the new instance.
+ * @return static A new instance with the specified path.
+ * @throws \InvalidArgumentException for invalid paths.
+ */
+ public function withPath(string $path): UriInterface;
+ /**
+ * Return an instance with the specified query string.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified query string.
+ *
+ * Users can provide both encoded and decoded query characters.
+ * Implementations ensure the correct encoding as outlined in getQuery().
+ *
+ * An empty query string value is equivalent to removing the query string.
+ *
+ * @param string $query The query string to use with the new instance.
+ * @return static A new instance with the specified query string.
+ * @throws \InvalidArgumentException for invalid query strings.
+ */
+ public function withQuery(string $query): UriInterface;
+ /**
+ * Return an instance with the specified URI fragment.
+ *
+ * This method MUST retain the state of the current instance, and return
+ * an instance that contains the specified URI fragment.
+ *
+ * Users can provide both encoded and decoded fragment characters.
+ * Implementations ensure the correct encoding as outlined in getFragment().
+ *
+ * An empty fragment value is equivalent to removing the fragment.
+ *
+ * @param string $fragment The fragment to use with the new instance.
+ * @return static A new instance with the specified fragment.
+ */
+ public function withFragment(string $fragment): UriInterface;
+ /**
+ * Return the string representation as a URI reference.
+ *
+ * Depending on which components of the URI are present, the resulting
+ * string is either a full URI or relative reference according to RFC 3986,
+ * Section 4.1. The method concatenates the various components of the URI,
+ * using the appropriate delimiters:
+ *
+ * - If a scheme is present, it MUST be suffixed by ":".
+ * - If an authority is present, it MUST be prefixed by "//".
+ * - The path can be concatenated without delimiters. But there are two
+ * cases where the path has to be adjusted to make the URI reference
+ * valid as PHP does not allow to throw an exception in __toString():
+ * - If the path is rootless and an authority is present, the path MUST
+ * be prefixed by "/".
+ * - If the path is starting with more than one "/" and no authority is
+ * present, the starting slashes MUST be reduced to one.
+ * - If a query is present, it MUST be prefixed by "?".
+ * - If a fragment is present, it MUST be prefixed by "#".
+ *
+ * @see http://tools.ietf.org/html/rfc3986#section-4.1
+ * @return string
+ */
+ public function __toString(): string;
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheInterface.php
new file mode 100644
index 0000000000000..b7fd4bf5b046d
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheInterface.php
@@ -0,0 +1,107 @@
+ value pairs. Cache keys that do not exist or are stale will have $default as value.
+ *
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ * MUST be thrown if $keys is neither an array nor a Traversable,
+ * or if any of the $keys are not a legal value.
+ */
+ public function getMultiple($keys, $default = null);
+ /**
+ * Persists a set of key => value pairs in the cache, with an optional TTL.
+ *
+ * @param iterable $values A list of key => value pairs for a multiple-set operation.
+ * @param null|int|\DateInterval $ttl Optional. The TTL value of this item. If no value is sent and
+ * the driver supports TTL then the library may set a default value
+ * for it or let the driver take care of that.
+ *
+ * @return bool True on success and false on failure.
+ *
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ * MUST be thrown if $values is neither an array nor a Traversable,
+ * or if any of the $values are not a legal value.
+ */
+ public function setMultiple($values, $ttl = null);
+ /**
+ * Deletes multiple cache items in a single operation.
+ *
+ * @param iterable $keys A list of string-based keys to be deleted.
+ *
+ * @return bool True if the items were successfully removed. False if there was an error.
+ *
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ * MUST be thrown if $keys is neither an array nor a Traversable,
+ * or if any of the $keys are not a legal value.
+ */
+ public function deleteMultiple($keys);
+ /**
+ * Determines whether an item is present in the cache.
+ *
+ * NOTE: It is recommended that has() is only to be used for cache warming type purposes
+ * and not to be used within your live applications operations for get/set, as this method
+ * is subject to a race condition where your has() will return true and immediately after,
+ * another script can remove it making the state of your app out of date.
+ *
+ * @param string $key The cache item key.
+ *
+ * @return bool
+ *
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ * MUST be thrown if the $key string is not a legal value.
+ */
+ public function has($key);
+}
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 60c220100f539..30bf33b753be7 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -286,6 +286,31 @@
require ABSPATH . WPINC . '/class-wp-http-response.php';
require ABSPATH . WPINC . '/class-wp-http-requests-response.php';
require ABSPATH . WPINC . '/class-wp-http-requests-hooks.php';
+require ABSPATH . WPINC . '/php-ai-client/autoload.php';
+
+// WP AI Client - PSR-7 implementations.
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-stream.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-uri.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-request.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-response.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr17-factory.php';
+
+// WP AI Client - HTTP transport and infrastructure.
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-http-client.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-cache.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-discovery-strategy.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-event-dispatcher.php';
+
+// WP AI Client - Abilities and prompt builder.
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-ability-function-resolver.php';
+require ABSPATH . WPINC . '/class-wp-ai-client-prompt-builder.php';
+require ABSPATH . WPINC . '/ai-client.php';
+
+// WP AI Client - Initialization.
+WP_AI_Client_Discovery_Strategy::init();
+WordPress\AiClient\AiClient::setCache( new WP_AI_Client_Cache() );
+WordPress\AiClient\AiClient::setEventDispatcher( new WP_AI_Client_Event_Dispatcher() );
+
require ABSPATH . WPINC . '/widgets.php';
require ABSPATH . WPINC . '/class-wp-widget.php';
require ABSPATH . WPINC . '/class-wp-widget-factory.php';
diff --git a/tests/phpunit/includes/wp-ai-client-mock-event.php b/tests/phpunit/includes/wp-ai-client-mock-event.php
new file mode 100644
index 0000000000000..6880da306d336
--- /dev/null
+++ b/tests/phpunit/includes/wp-ai-client-mock-event.php
@@ -0,0 +1,17 @@
+create_test_text_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextGenerationModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private GenerativeAiResult $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ GenerativeAiResult $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 generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return $this->result;
+ }
+
+ public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ yield $this->result;
+ }
+ };
+ }
+
+ /**
+ * Creates a mock image generation model using anonymous class.
+ *
+ * @param GenerativeAiResult $result The result to return from generation.
+ * @param ModelMetadata|null $metadata Optional metadata.
+ * @return ModelInterface&ImageGenerationModelInterface The mock model.
+ */
+ protected function create_mock_image_generation_model(
+ GenerativeAiResult $result,
+ ?ModelMetadata $metadata = null
+ ): ModelInterface {
+ $metadata = $metadata ?? $this->create_test_image_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, ImageGenerationModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private GenerativeAiResult $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ GenerativeAiResult $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 generateImageResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return $this->result;
+ }
+ };
+ }
+
+ /**
+ * Creates a mock speech generation model using anonymous class.
+ *
+ * @param GenerativeAiResult $result The result to return from generation.
+ * @param ModelMetadata|null $metadata Optional metadata.
+ * @return ModelInterface&SpeechGenerationModelInterface The mock model.
+ */
+ protected function create_mock_speech_generation_model(
+ GenerativeAiResult $result,
+ ?ModelMetadata $metadata = null
+ ): ModelInterface {
+ $metadata = $metadata ?? $this->create_test_speech_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock-provider',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, SpeechGenerationModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private GenerativeAiResult $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ GenerativeAiResult $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 generateSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return $this->result;
+ }
+ };
+ }
+
+ /**
+ * Creates a mock text-to-speech conversion model using anonymous class.
+ *
+ * @param GenerativeAiResult $result The result to return from conversion.
+ * @param ModelMetadata|null $metadata Optional metadata.
+ * @return ModelInterface&TextToSpeechConversionModelInterface The mock model.
+ */
+ protected function create_mock_text_to_speech_model(
+ GenerativeAiResult $result,
+ ?ModelMetadata $metadata = null
+ ): ModelInterface {
+ $metadata = $metadata ?? $this->create_test_text_to_speech_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock-provider',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextToSpeechConversionModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private GenerativeAiResult $result;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ GenerativeAiResult $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 convertTextToSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ return $this->result;
+ }
+ };
+ }
+
+ /**
+ * Creates a mock text generation model that throws an exception.
+ *
+ * @param Exception $exception The exception to throw from generation.
+ * @param ModelMetadata|null $metadata Optional metadata.
+ * @return ModelInterface&TextGenerationModelInterface The mock model.
+ */
+ protected function create_mock_text_generation_model_with_exception(
+ Exception $exception,
+ ?ModelMetadata $metadata = null
+ ): ModelInterface {
+ $metadata = $metadata ?? $this->create_test_text_model_metadata();
+
+ $provider_metadata = new ProviderMetadata(
+ 'mock',
+ 'Mock Provider',
+ ProviderTypeEnum::cloud()
+ );
+
+ return new class( $metadata, $provider_metadata, $exception ) implements ModelInterface, TextGenerationModelInterface {
+
+ private ModelMetadata $metadata;
+ private ProviderMetadata $provider_metadata;
+ private Exception $exception;
+ private ModelConfig $config;
+
+ public function __construct(
+ ModelMetadata $metadata,
+ ProviderMetadata $provider_metadata,
+ Exception $exception
+ ) {
+ $this->metadata = $metadata;
+ $this->provider_metadata = $provider_metadata;
+ $this->exception = $exception;
+ $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 generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ throw $this->exception;
+ }
+
+ public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+ throw $this->exception;
+ }
+ };
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php
new file mode 100644
index 0000000000000..fb5e2fefb9f29
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php
@@ -0,0 +1,757 @@
+ 'WP AI Client Tests',
+ 'description' => 'Test abilities for WP AI Client.',
+ )
+ );
+
+ array_pop( $wp_current_filter );
+
+ // Simulate the abilities init action.
+ $wp_current_filter[] = 'wp_abilities_api_init';
+
+ // Register test abilities.
+ wp_register_ability(
+ 'wpaiclienttests/simple',
+ array(
+ 'label' => 'Simple Test Ability',
+ 'description' => 'A simple test ability with no parameters.',
+ 'category' => 'wpaiclienttests',
+ 'execute_callback' => static function () {
+ return array( 'success' => true );
+ },
+ 'permission_callback' => static function () {
+ return true;
+ },
+ )
+ );
+
+ wp_register_ability(
+ 'wpaiclienttests/with-params',
+ array(
+ 'label' => 'Test Ability With Parameters',
+ 'description' => 'A test ability that accepts parameters.',
+ 'category' => 'wpaiclienttests',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'title' => array(
+ 'type' => 'string',
+ 'description' => 'The title parameter.',
+ 'required' => true,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ return array(
+ 'success' => true,
+ 'title' => $input['title'],
+ );
+ },
+ 'permission_callback' => static function () {
+ return true;
+ },
+ )
+ );
+
+ wp_register_ability(
+ 'wpaiclienttests/returns-error',
+ array(
+ 'label' => 'Test Ability That Returns Error',
+ 'description' => 'A test ability that returns a WP_Error.',
+ 'category' => 'wpaiclienttests',
+ 'execute_callback' => static function () {
+ return new WP_Error( 'test_error', 'This is a test error message.' );
+ },
+ 'permission_callback' => static function () {
+ return true;
+ },
+ )
+ );
+
+ wp_register_ability(
+ 'wpaiclienttests/hyphen-test',
+ array(
+ 'label' => 'Test Ability With Hyphens',
+ 'description' => 'A test ability to verify hyphenated names.',
+ 'category' => 'wpaiclienttests',
+ 'execute_callback' => static function () {
+ return array( 'hyphenated' => true );
+ },
+ 'permission_callback' => static function () {
+ return true;
+ },
+ )
+ );
+
+ array_pop( $wp_current_filter );
+ }
+
+ /**
+ * Test that is_ability_call returns true for a valid ability call.
+ *
+ * @ticket TBD
+ */
+ public function test_is_ability_call_returns_true_for_valid_ability() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__tec__create_event',
+ array()
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test that is_ability_call returns true for a nested namespace.
+ *
+ * @ticket TBD
+ */
+ public function test_is_ability_call_returns_true_for_nested_namespace() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__tec__v1__create_event',
+ array()
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test that is_ability_call returns false for a non-ability call.
+ *
+ * @ticket TBD
+ */
+ public function test_is_ability_call_returns_false_for_non_ability() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'regular_function',
+ array()
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that is_ability_call returns false when name is null.
+ *
+ * @ticket TBD
+ */
+ public function test_is_ability_call_returns_false_when_name_is_null() {
+ $call = new FunctionCall(
+ 'test-id',
+ null,
+ array()
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that is_ability_call returns false for partial prefix.
+ *
+ * @ticket TBD
+ */
+ public function test_is_ability_call_returns_false_for_partial_prefix() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab_single_underscore',
+ array()
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that execute_ability returns error for non-ability call.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_returns_error_for_non_ability_call() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'regular_function',
+ array()
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'test-id', $response->getId() );
+ $this->assertSame( 'regular_function', $response->getName() );
+ $data = $response->getResponse();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'error', $data );
+ $this->assertSame( 'Not an ability function call', $data['error'] );
+ $this->assertArrayHasKey( 'code', $data );
+ $this->assertSame( 'invalid_ability_call', $data['code'] );
+ }
+
+ /**
+ * Test that execute_ability returns error when ability not found.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_returns_error_when_ability_not_found() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__nonexistent__ability',
+ array()
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'test-id', $response->getId() );
+ $this->assertSame( 'wpab__nonexistent__ability', $response->getName() );
+ $data = $response->getResponse();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'error', $data );
+ $this->assertStringContainsString( 'not found', $data['error'] );
+ $this->assertArrayHasKey( 'code', $data );
+ $this->assertSame( 'ability_not_found', $data['code'] );
+ }
+
+ /**
+ * Test that execute_ability handles missing id.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_handles_missing_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call = new FunctionCall(
+ null,
+ 'wpab__nonexistent__ability',
+ array()
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'unknown', $response->getId() );
+ }
+
+ /**
+ * Test that has_ability_calls returns true when ability call is present.
+ *
+ * @ticket TBD
+ */
+ public function test_has_ability_calls_returns_true_when_present() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__tec__create_event',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( 'Here is the result:' ),
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test that has_ability_calls returns false when ability call is not present.
+ *
+ * @ticket TBD
+ */
+ public function test_has_ability_calls_returns_false_when_not_present() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'regular_function',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( 'Here is the result:' ),
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that has_ability_calls returns false for text-only message.
+ *
+ * @ticket TBD
+ */
+ public function test_has_ability_calls_returns_false_for_text_only() {
+ $message = new UserMessage(
+ array(
+ new MessagePart( 'Just some text' ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that has_ability_calls returns true with mixed content.
+ *
+ * @ticket TBD
+ */
+ public function test_has_ability_calls_returns_true_with_mixed_content() {
+ $regular_call = new FunctionCall(
+ 'regular-id',
+ 'regular_function',
+ array()
+ );
+
+ $ability_call = new FunctionCall(
+ 'ability-id',
+ 'wpab__tec__create_event',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( 'Some text' ),
+ new MessagePart( $regular_call ),
+ new MessagePart( $ability_call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test that has_ability_calls handles empty message.
+ *
+ * @ticket TBD
+ */
+ public function test_has_ability_calls_with_empty_message() {
+ $message = new ModelMessage( array() );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test that execute_abilities handles empty message.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_with_empty_message() {
+ $message = new ModelMessage( array() );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $this->assertCount( 0, $result->getParts() );
+ }
+
+ /**
+ * Test that execute_abilities handles errors gracefully.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_handles_errors_gracefully() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__nonexistent__ability',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ $this->assertCount( 1, $parts );
+
+ $response = $parts[0]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $data = $response->getResponse();
+ $this->assertArrayHasKey( 'error', $data );
+ }
+
+ /**
+ * Test that execute_abilities returns a UserMessage.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_returns_user_message() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__nonexistent__ability',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ }
+
+ /**
+ * Test that execute_abilities processes multiple calls.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_processes_multiple_calls() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call1 = new FunctionCall(
+ 'call-1',
+ 'wpab__nonexistent__ability1',
+ array()
+ );
+
+ $call2 = new FunctionCall(
+ 'call-2',
+ 'wpab__nonexistent__ability2',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call1 ),
+ new MessagePart( $call2 ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ $this->assertCount( 2, $parts );
+ }
+
+ /**
+ * Test that execute_abilities only processes function calls.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_only_processes_function_calls() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__nonexistent__ability',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( 'Some text' ),
+ new MessagePart( $call ),
+ new MessagePart( 'More text' ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ // Only the function call should be processed.
+ $this->assertCount( 1, $parts );
+ }
+
+ /**
+ * Test ability_name_to_function_name with simple name.
+ *
+ * @ticket TBD
+ */
+ public function test_ability_name_to_function_name_simple() {
+ $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/create_event' );
+
+ $this->assertSame( 'wpab__tec__create_event', $result );
+ }
+
+ /**
+ * Test ability_name_to_function_name with nested namespace.
+ *
+ * @ticket TBD
+ */
+ public function test_ability_name_to_function_name_nested() {
+ $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/v1/create_event' );
+
+ $this->assertSame( 'wpab__tec__v1__create_event', $result );
+ }
+
+ /**
+ * Test execute_ability with successful execution.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_success() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__simple',
+ array()
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'test-id', $response->getId() );
+ $this->assertSame( 'wpab__wpaiclienttests__simple', $response->getName() );
+ $data = $response->getResponse();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'success', $data );
+ $this->assertTrue( $data['success'] );
+ }
+
+ /**
+ * Test execute_ability with parameters.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_with_parameters() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__with-params',
+ array( 'title' => 'Test Title' )
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'test-id', $response->getId() );
+ $this->assertSame( 'wpab__wpaiclienttests__with-params', $response->getName() );
+ $data = $response->getResponse();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'success', $data );
+ $this->assertTrue( $data['success'] );
+ $this->assertArrayHasKey( 'title', $data );
+ $this->assertSame( 'Test Title', $data['title'] );
+ }
+
+ /**
+ * Test execute_ability handles WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_ability_handles_wp_error() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__returns-error',
+ array()
+ );
+
+ $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call );
+
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $this->assertSame( 'test-id', $response->getId() );
+ $this->assertSame( 'wpab__wpaiclienttests__returns-error', $response->getName() );
+ $data = $response->getResponse();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'error', $data );
+ $this->assertSame( 'This is a test error message.', $data['error'] );
+ $this->assertArrayHasKey( 'code', $data );
+ $this->assertSame( 'test_error', $data['code'] );
+ }
+
+ /**
+ * Test execute_abilities with successful execution.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_success() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__simple',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ $this->assertCount( 1, $parts );
+
+ $response = $parts[0]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $data = $response->getResponse();
+ $this->assertArrayHasKey( 'success', $data );
+ $this->assertTrue( $data['success'] );
+ }
+
+ /**
+ * Test execute_abilities with multiple successful executions.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_multiple_success() {
+ $call1 = new FunctionCall(
+ 'call-1',
+ 'wpab__wpaiclienttests__simple',
+ array()
+ );
+
+ $call2 = new FunctionCall(
+ 'call-2',
+ 'wpab__wpaiclienttests__hyphen-test',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call1 ),
+ new MessagePart( $call2 ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ $this->assertCount( 2, $parts );
+
+ // Check first response.
+ $response1 = $parts[0]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response1 );
+ $data1 = $response1->getResponse();
+ $this->assertArrayHasKey( 'success', $data1 );
+ $this->assertTrue( $data1['success'] );
+
+ // Check second response.
+ $response2 = $parts[1]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response2 );
+ $data2 = $response2->getResponse();
+ $this->assertArrayHasKey( 'hyphenated', $data2 );
+ $this->assertTrue( $data2['hyphenated'] );
+ }
+
+ /**
+ * Test execute_abilities with mixed text and ability calls.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_with_mixed_content() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__simple',
+ array()
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( 'Starting execution' ),
+ new MessagePart( $call ),
+ new MessagePart( 'Execution complete' ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ // Only function calls should be processed.
+ $this->assertCount( 1, $parts );
+
+ $response = $parts[0]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ }
+
+ /**
+ * Test execute_abilities with ability that has parameters.
+ *
+ * @ticket TBD
+ */
+ public function test_execute_abilities_with_parameters() {
+ $call = new FunctionCall(
+ 'test-id',
+ 'wpab__wpaiclienttests__with-params',
+ array( 'title' => 'Integration Test' )
+ );
+
+ $message = new ModelMessage(
+ array(
+ new MessagePart( $call ),
+ )
+ );
+
+ $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message );
+
+ $this->assertInstanceOf( UserMessage::class, $result );
+ $parts = $result->getParts();
+ $this->assertCount( 1, $parts );
+
+ $response = $parts[0]->getFunctionResponse();
+ $this->assertInstanceOf( FunctionResponse::class, $response );
+ $data = $response->getResponse();
+ $this->assertArrayHasKey( 'success', $data );
+ $this->assertTrue( $data['success'] );
+ $this->assertArrayHasKey( 'title', $data );
+ $this->assertSame( 'Integration Test', $data['title'] );
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientCache.php b/tests/phpunit/tests/ai-client/wpAiClientCache.php
new file mode 100644
index 0000000000000..77a18b09aa13a
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientCache.php
@@ -0,0 +1,175 @@
+cache = new WP_AI_Client_Cache();
+ }
+
+ /**
+ * Test that the cache implements the scoped PSR-16 CacheInterface.
+ *
+ * @ticket TBD
+ */
+ public function test_implements_cache_interface() {
+ $this->assertInstanceOf(
+ WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface::class,
+ $this->cache
+ );
+ }
+
+ /**
+ * Test that get returns default value on cache miss.
+ *
+ * @ticket TBD
+ */
+ public function test_get_returns_default_on_miss() {
+ $this->assertNull( $this->cache->get( 'nonexistent' ) );
+ $this->assertSame( 'fallback', $this->cache->get( 'nonexistent', 'fallback' ) );
+ }
+
+ /**
+ * Test set and get round-trip.
+ *
+ * @ticket TBD
+ */
+ public function test_set_and_get() {
+ $this->assertTrue( $this->cache->set( 'key1', 'value1' ) );
+ $this->assertSame( 'value1', $this->cache->get( 'key1' ) );
+ }
+
+ /**
+ * Test delete removes cached item.
+ *
+ * @ticket TBD
+ */
+ public function test_delete() {
+ $this->cache->set( 'key1', 'value1' );
+ $this->assertTrue( $this->cache->delete( 'key1' ) );
+ $this->assertNull( $this->cache->get( 'key1' ) );
+ }
+
+ /**
+ * Test has returns false on cache miss.
+ *
+ * @ticket TBD
+ */
+ public function test_has_returns_false_on_miss() {
+ $this->assertFalse( $this->cache->has( 'nonexistent' ) );
+ }
+
+ /**
+ * Test has returns true on cache hit.
+ *
+ * @ticket TBD
+ */
+ public function test_has_returns_true_on_hit() {
+ $this->cache->set( 'key1', 'value1' );
+ $this->assertTrue( $this->cache->has( 'key1' ) );
+ }
+
+ /**
+ * Test getMultiple returns values and defaults.
+ *
+ * @ticket TBD
+ */
+ public function test_get_multiple() {
+ $this->cache->set( 'key1', 'value1' );
+ $this->cache->set( 'key2', 'value2' );
+
+ $result = $this->cache->getMultiple( array( 'key1', 'key2', 'key3' ), 'default' );
+
+ $this->assertSame( 'value1', $result['key1'] );
+ $this->assertSame( 'value2', $result['key2'] );
+ $this->assertSame( 'default', $result['key3'] );
+ }
+
+ /**
+ * Test setMultiple stores multiple values.
+ *
+ * @ticket TBD
+ */
+ public function test_set_multiple() {
+ $this->assertTrue(
+ $this->cache->setMultiple(
+ array(
+ 'key1' => 'value1',
+ 'key2' => 'value2',
+ )
+ )
+ );
+
+ $this->assertSame( 'value1', $this->cache->get( 'key1' ) );
+ $this->assertSame( 'value2', $this->cache->get( 'key2' ) );
+ }
+
+ /**
+ * Test deleteMultiple removes multiple items.
+ *
+ * @ticket TBD
+ */
+ public function test_delete_multiple() {
+ $this->cache->set( 'key1', 'value1' );
+ $this->cache->set( 'key2', 'value2' );
+
+ $this->assertTrue( $this->cache->deleteMultiple( array( 'key1', 'key2' ) ) );
+ $this->assertNull( $this->cache->get( 'key1' ) );
+ $this->assertNull( $this->cache->get( 'key2' ) );
+ }
+
+ /**
+ * Test clear flushes the cache group.
+ *
+ * @ticket TBD
+ */
+ public function test_clear() {
+ $this->cache->set( 'key1', 'value1' );
+
+ // WordPress default object cache supports flush_group.
+ $result = $this->cache->clear();
+
+ if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
+ $this->assertTrue( $result );
+ $this->assertNull( $this->cache->get( 'key1' ) );
+ } else {
+ $this->assertFalse( $result );
+ }
+ }
+
+ /**
+ * Test set with integer TTL.
+ *
+ * @ticket TBD
+ */
+ public function test_ttl_with_integer() {
+ $this->assertTrue( $this->cache->set( 'key1', 'value1', 3600 ) );
+ $this->assertSame( 'value1', $this->cache->get( 'key1' ) );
+ }
+
+ /**
+ * Test set with DateInterval TTL.
+ *
+ * @ticket TBD
+ */
+ public function test_ttl_with_date_interval() {
+ $ttl = new DateInterval( 'PT1H' );
+ $this->assertTrue( $this->cache->set( 'key1', 'value1', $ttl ) );
+ $this->assertSame( 'value1', $this->cache->get( 'key1' ) );
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
new file mode 100644
index 0000000000000..3cd621f09bf2c
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
@@ -0,0 +1,55 @@
+dispatch( $event );
+
+ $this->assertTrue( $hook_fired, 'The action hook should have been fired' );
+ $this->assertSame( $event, $fired_event, 'The fired event should be the same as the dispatched event' );
+ $this->assertSame( $event, $result, 'The dispatch method should return the same event' );
+ }
+
+ /**
+ * Test that dispatch returns event without listeners.
+ *
+ * @ticket TBD
+ */
+ public function test_dispatch_returns_event_without_listeners() {
+ $dispatcher = new WP_AI_Client_Event_Dispatcher();
+ $event = new stdClass();
+ $event->test_value = 'original';
+
+ $result = $dispatcher->dispatch( $event );
+
+ $this->assertSame( $event, $result, 'The dispatch method should return the same object' );
+ $this->assertSame( 'original', $result->test_value, 'The event object should remain unchanged' );
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php
new file mode 100644
index 0000000000000..131ac315eb3e7
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php
@@ -0,0 +1,91 @@
+assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $builder );
+ }
+
+ /**
+ * Test that wp_ai_client_prompt() wraps a PromptBuilder internally.
+ *
+ * @ticket TBD
+ */
+ public function test_wraps_sdk_prompt_builder() {
+ $builder = wp_ai_client_prompt();
+
+ $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $property = $reflection->getProperty( 'builder' );
+
+ $this->assertInstanceOf( PromptBuilder::class, $property->getValue( $builder ) );
+ }
+
+ /**
+ * Test that wp_ai_client_prompt() passes prompt content to the builder.
+ *
+ * @ticket TBD
+ */
+ public function test_passes_prompt_content() {
+ $builder = wp_ai_client_prompt( 'Hello, AI!' );
+
+ $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $builder_property = $reflection->getProperty( 'builder' );
+
+ $wrapped = $builder_property->getValue( $builder );
+
+ $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) );
+ $messages_property = $wrapped_reflection->getProperty( 'messages' );
+
+ $messages = $messages_property->getValue( $wrapped );
+
+ $this->assertNotEmpty( $messages, 'Prompt content should produce at least one message.' );
+ }
+
+ /**
+ * Test that wp_ai_client_prompt() without arguments creates builder with no messages.
+ *
+ * @ticket TBD
+ */
+ public function test_no_prompt_creates_empty_builder() {
+ $builder = wp_ai_client_prompt();
+
+ $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $builder_property = $reflection->getProperty( 'builder' );
+
+ $wrapped = $builder_property->getValue( $builder );
+
+ $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) );
+ $messages_property = $wrapped_reflection->getProperty( 'messages' );
+
+ $messages = $messages_property->getValue( $wrapped );
+
+ $this->assertEmpty( $messages, 'No prompt content should produce no messages.' );
+ }
+
+ /**
+ * Test that successive calls return independent builder instances.
+ *
+ * @ticket TBD
+ */
+ public function test_returns_independent_instances() {
+ $builder1 = wp_ai_client_prompt( 'First' );
+ $builder2 = wp_ai_client_prompt( 'Second' );
+
+ $this->assertNotSame( $builder1, $builder2 );
+ }
+}
diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
new file mode 100644
index 0000000000000..d1d7f73e853f0
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
@@ -0,0 +1,2409 @@
+getProperty( 'builder' );
+ $wrapped_builder = $builder_property->getValue( $builder );
+
+ $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) );
+ $the_property = $reflection_class2->getProperty( $property );
+
+ return $the_property->getValue( $wrapped_builder );
+ }
+
+ /**
+ * Gets the function declarations from the builder's model config.
+ *
+ * @param WP_AI_Client_Prompt_Builder $builder The builder to get declarations from.
+ * @return list|null The function declarations or null if not set.
+ */
+ private function get_function_declarations( WP_AI_Client_Prompt_Builder $builder ): ?array {
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+ return $config->getFunctionDeclarations();
+ }
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ $this->registry = $this->createMock( ProviderRegistry::class );
+ }
+
+ /**
+ * Test that WP_AI_Client_Prompt_Builder can be instantiated.
+ *
+ * @ticket TBD
+ */
+ public function test_instantiation() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder );
+
+ // Verify the wrapped builder is a PromptBuilder instance.
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $builder_property = $reflection_class->getProperty( 'builder' );
+
+ $wrapped_builder = $builder_property->getValue( $prompt_builder );
+
+ $this->assertInstanceOf( PromptBuilder::class, $wrapped_builder );
+ }
+
+ /**
+ * Test that WP_AI_Client_Prompt_Builder can be instantiated with initial prompt content.
+ *
+ * @ticket TBD
+ */
+ public function test_instantiation_with_prompt() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Initial prompt text' );
+
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder );
+ }
+
+ /**
+ * Test that the constructor sets the default request timeout.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_sets_default_request_timeout() {
+ $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
+
+ /** @var RequestOptions $request_options */
+ $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' );
+
+ $this->assertInstanceOf( RequestOptions::class, $request_options );
+ $this->assertEquals( 30, $request_options->getTimeout() );
+ }
+
+ /**
+ * Test that the constructor allows overriding the default request timeout.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_allows_overriding_request_timeout() {
+ add_filter(
+ 'wp_ai_client_default_request_timeout',
+ static function () {
+ return 45;
+ }
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
+
+ /** @var RequestOptions $request_options */
+ $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' );
+
+ $this->assertInstanceOf( RequestOptions::class, $request_options );
+ $this->assertEquals( 45, $request_options->getTimeout() );
+ }
+
+ /**
+ * Test method chaining with fluent methods.
+ *
+ * @ticket TBD
+ */
+ public function test_method_chaining_returns_decorator() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $result = $prompt_builder->with_text( 'Test text' );
+ $this->assertSame( $prompt_builder, $result, 'with_text should return the decorator instance' );
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result );
+
+ $result = $prompt_builder->using_system_instruction( 'System instruction' );
+ $this->assertSame( $prompt_builder, $result, 'using_system_instruction should return the decorator instance' );
+
+ $result = $prompt_builder->using_max_tokens( 100 );
+ $this->assertSame( $prompt_builder, $result, 'using_max_tokens should return the decorator instance' );
+
+ $result = $prompt_builder->using_temperature( 0.7 );
+ $this->assertSame( $prompt_builder, $result, 'using_temperature should return the decorator instance' );
+
+ $result = $prompt_builder->using_top_p( 0.9 );
+ $this->assertSame( $prompt_builder, $result, 'using_top_p should return the decorator instance' );
+
+ $result = $prompt_builder->using_top_k( 50 );
+ $this->assertSame( $prompt_builder, $result, 'using_top_k should return the decorator instance' );
+
+ $result = $prompt_builder->using_presence_penalty( 0.5 );
+ $this->assertSame( $prompt_builder, $result, 'using_presence_penalty should return the decorator instance' );
+
+ $result = $prompt_builder->using_frequency_penalty( 0.5 );
+ $this->assertSame( $prompt_builder, $result, 'using_frequency_penalty should return the decorator instance' );
+
+ $result = $prompt_builder->as_output_mime_type( 'application/json' );
+ $this->assertSame( $prompt_builder, $result, 'as_output_mime_type should return the decorator instance' );
+ }
+
+ /**
+ * Test complex method chaining scenario.
+ *
+ * @ticket TBD
+ */
+ public function test_complex_method_chaining() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $result = $prompt_builder
+ ->with_text( 'Test prompt' )
+ ->using_system_instruction( 'You are a helpful assistant' )
+ ->using_max_tokens( 500 )
+ ->using_temperature( 0.7 )
+ ->using_top_p( 0.9 );
+
+ $this->assertSame( $prompt_builder, $result, 'Chained methods should return the same decorator instance' );
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result );
+ }
+
+ /**
+ * Test that boolean-returning methods do not return the decorator.
+ *
+ * @ticket TBD
+ */
+ public function test_boolean_methods_return_boolean() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Test text' );
+
+ $result = $prompt_builder->is_supported_for_text_generation();
+ $this->assertIsBool( $result, 'is_supported_for_text_generation should return a boolean' );
+ $this->assertNotSame( $prompt_builder, $result, 'is_supported_for_text_generation should not return the decorator' );
+ }
+
+ /**
+ * Test snake_case to camelCase conversion.
+ *
+ * @ticket TBD
+ */
+ public function test_snake_case_to_camel_case_conversion() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $test_cases = array(
+ 'with_text' => 'withText',
+ 'using_system_instruction' => 'usingSystemInstruction',
+ 'using_max_tokens' => 'usingMaxTokens',
+ 'as_output_mime_type' => 'asOutputMimeType',
+ 'using_model_config' => 'usingModelConfig',
+ 'with_message_parts' => 'withMessageParts',
+ 'using_stop_sequences' => 'usingStopSequences',
+ 'using_candidate_count' => 'usingCandidateCount',
+ 'using_function_declarations' => 'usingFunctionDeclarations',
+ );
+
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $conversion_method = $reflection_class->getMethod( 'snake_to_camel_case' );
+
+ foreach ( $test_cases as $snake_case => $expected_camel_case ) {
+ $actual_camel_case = $conversion_method->invoke( $prompt_builder, $snake_case );
+ $this->assertSame( $expected_camel_case, $actual_camel_case, "Failed converting {$snake_case} to {$expected_camel_case}" );
+ }
+ }
+
+ /**
+ * Test that calling a non-existent method returns WP_Error on termination.
+ *
+ * @ticket TBD
+ */
+ public function test_invalid_method_returns_wp_error() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ // Invalid method call stores error but returns $this for chaining.
+ $result = $prompt_builder->non_existent_method();
+ $this->assertSame( $prompt_builder, $result );
+
+ // Calling a terminate method should return the stored WP_Error.
+ $result = $prompt_builder->generate_text();
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'non_existent_method does not exist', $result->get_error_message() );
+ }
+
+ /**
+ * Test that get_builder_callable returns a valid callable.
+ *
+ * @ticket TBD
+ */
+ public function test_get_builder_callable() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $callable_method = $reflection_class->getMethod( 'get_builder_callable' );
+
+ $callable = $callable_method->invoke( $prompt_builder, 'with_text' );
+ $this->assertTrue( is_callable( $callable ), 'get_builder_callable should return a valid callable' );
+
+ $this->assertIsArray( $callable );
+ $this->assertCount( 2, $callable );
+ $this->assertInstanceOf( PromptBuilder::class, $callable[0] );
+ $this->assertSame( 'withText', $callable[1] );
+ }
+
+ /**
+ * Test that the wrapped builder is properly configured with the registry.
+ *
+ * @ticket TBD
+ */
+ 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' );
+
+ $wrapped_builder = $builder_property->getValue( $prompt_builder );
+
+ $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) );
+ $registry_property = $wrapped_builder_reflection->getProperty( 'registry' );
+
+ $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' );
+ }
+
+ /**
+ * Test method chaining with with_history.
+ *
+ * @ticket TBD
+ */
+ public function test_method_chaining_with_history() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $message1 = Message::fromArray(
+ array(
+ 'role' => 'user',
+ 'parts' => array(
+ array(
+ 'text' => 'Hello',
+ ),
+ ),
+ )
+ );
+ $message2 = Message::fromArray(
+ array(
+ 'role' => 'user',
+ 'parts' => array(
+ array(
+ 'text' => 'How are you?',
+ ),
+ ),
+ )
+ );
+
+ $result = $prompt_builder->with_history( $message1, $message2 );
+ $this->assertSame( $prompt_builder, $result, 'with_history should return the decorator instance' );
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result );
+ }
+
+ /**
+ * Test method chaining with using_model_config.
+ *
+ * @ticket TBD
+ */
+ public function test_method_chaining_with_model_config() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $config = new ModelConfig( array( 'maxTokens' => 100 ) );
+
+ $result = $prompt_builder->using_model_config( $config );
+ $this->assertSame( $prompt_builder, $result, 'using_model_config should return the decorator instance' );
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result );
+ }
+
+ /**
+ * Tests constructor with no prompt.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_no_prompt() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+ $this->assertEmpty( $messages );
+ }
+
+ /**
+ * Tests constructor with string prompt.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_string_prompt() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Hello, world!' );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertInstanceOf( Message::class, $messages[0] );
+ $this->assertEquals( 'Hello, world!', $messages[0]->getParts()[0]->getText() );
+ }
+
+ /**
+ * Tests constructor with MessagePart prompt.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_message_part_prompt() {
+ $part = new MessagePart( 'Test message' );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $part );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertInstanceOf( Message::class, $messages[0] );
+ $this->assertEquals( 'Test message', $messages[0]->getParts()[0]->getText() );
+ }
+
+ /**
+ * Tests constructor with Message prompt.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_message_prompt() {
+ $message = new UserMessage( array( new MessagePart( 'User message' ) ) );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertSame( $message, $messages[0] );
+ }
+
+ /**
+ * Tests constructor with list of Messages.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_messages_list() {
+ $messages = array(
+ new UserMessage( array( new MessagePart( 'First' ) ) ),
+ new ModelMessage( array( new MessagePart( 'Second' ) ) ),
+ new UserMessage( array( new MessagePart( 'Third' ) ) ),
+ );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $messages );
+
+ /** @var list $actual_messages */
+ $actual_messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 3, $actual_messages );
+ $this->assertSame( $messages, $actual_messages );
+ }
+
+ /**
+ * Tests constructor with MessageArrayShape.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_message_array_shape() {
+ $message_array = array(
+ 'role' => 'user',
+ 'parts' => array(
+ array(
+ 'type' => 'text',
+ 'text' => 'Hello from array',
+ ),
+ ),
+ );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message_array );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertInstanceOf( Message::class, $messages[0] );
+ $this->assertEquals( 'Hello from array', $messages[0]->getParts()[0]->getText() );
+ }
+
+ /**
+ * Tests withText method.
+ *
+ * @ticket TBD
+ */
+ public function test_with_text() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_text( 'Some text' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertEquals( 'Some text', $messages[0]->getParts()[0]->getText() );
+ }
+
+ /**
+ * Tests withText appends to existing user message.
+ *
+ * @ticket TBD
+ */
+ public function test_with_text_appends_to_existing_user_message() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Initial text' );
+ $builder->with_text( ' Additional text' );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $parts = $messages[0]->getParts();
+ $this->assertCount( 2, $parts );
+ $this->assertEquals( 'Initial text', $parts[0]->getText() );
+ $this->assertEquals( ' Additional text', $parts[1]->getText() );
+ }
+
+ /**
+ * Tests withFile method with base64 data.
+ *
+ * @ticket TBD
+ */
+ public function test_with_inline_file() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+ $result = $builder->with_file( $base64, 'image/png' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $file = $messages[0]->getParts()[0]->getFile();
+ $this->assertInstanceOf( File::class, $file );
+ $this->assertEquals( 'data:image/png;base64,' . $base64, $file->getDataUri() );
+ $this->assertEquals( 'image/png', $file->getMimeType() );
+ }
+
+ /**
+ * Tests withFile method with remote URL.
+ *
+ * @ticket TBD
+ */
+ public function test_with_remote_file() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_file( 'https://example.com/image.jpg', 'image/jpeg' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $file = $messages[0]->getParts()[0]->getFile();
+ $this->assertInstanceOf( File::class, $file );
+ $this->assertEquals( 'https://example.com/image.jpg', $file->getUrl() );
+ $this->assertEquals( 'image/jpeg', $file->getMimeType() );
+ }
+
+ /**
+ * Tests withFile with data URI.
+ *
+ * @ticket TBD
+ */
+ public function test_with_inline_file_data_uri() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $data_uri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg==';
+ $result = $builder->with_file( $data_uri );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $file = $messages[0]->getParts()[0]->getFile();
+ $this->assertInstanceOf( File::class, $file );
+ $this->assertEquals( 'image/jpeg', $file->getMimeType() );
+ }
+
+ /**
+ * Tests withFile with URL without explicit MIME type.
+ *
+ * @ticket TBD
+ */
+ public function test_with_remote_file_without_mime_type() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_file( 'https://example.com/audio.mp3' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $file = $messages[0]->getParts()[0]->getFile();
+ $this->assertInstanceOf( File::class, $file );
+ $this->assertEquals( 'https://example.com/audio.mp3', $file->getUrl() );
+ $this->assertEquals( 'audio/mpeg', $file->getMimeType() );
+ }
+
+ /**
+ * Tests withFunctionResponse method.
+ *
+ * @ticket TBD
+ */
+ public function test_with_function_response() {
+ $function_response = new FunctionResponse( 'func_id', 'func_name', array( 'result' => 'data' ) );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_function_response( $function_response );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertSame( $function_response, $messages[0]->getParts()[0]->getFunctionResponse() );
+ }
+
+ /**
+ * Tests withMessageParts method.
+ *
+ * @ticket TBD
+ */
+ public function test_with_message_parts() {
+ $part1 = new MessagePart( 'Part 1' );
+ $part2 = new MessagePart( 'Part 2' );
+ $part3 = new MessagePart( 'Part 3' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_message_parts( $part1, $part2, $part3 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $parts = $messages[0]->getParts();
+ $this->assertCount( 3, $parts );
+ $this->assertEquals( 'Part 1', $parts[0]->getText() );
+ $this->assertEquals( 'Part 2', $parts[1]->getText() );
+ $this->assertEquals( 'Part 3', $parts[2]->getText() );
+ }
+
+ /**
+ * Tests withHistory method.
+ *
+ * @ticket TBD
+ */
+ public function test_with_history() {
+ $history = array(
+ new UserMessage( array( new MessagePart( 'User 1' ) ) ),
+ new ModelMessage( array( new MessagePart( 'Model 1' ) ) ),
+ new UserMessage( array( new MessagePart( 'User 2' ) ) ),
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->with_history( ...$history );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 3, $messages );
+ $this->assertEquals( 'User 1', $messages[0]->getParts()[0]->getText() );
+ $this->assertEquals( 'Model 1', $messages[1]->getParts()[0]->getText() );
+ $this->assertEquals( 'User 2', $messages[2]->getParts()[0]->getText() );
+ }
+
+ /**
+ * Tests usingModel method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model() {
+ $model_config = new ModelConfig();
+ $model = $this->createMock( ModelInterface::class );
+ $model->method( 'getConfig' )->willReturn( $model_config );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_model( $model );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelInterface $actual_model */
+ $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' );
+ $this->assertSame( $model, $actual_model );
+ }
+
+ /**
+ * Tests constructor with list of string parts.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_string_parts_list() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'Part 1', 'Part 2', 'Part 3' ) );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $this->assertInstanceOf( Message::class, $messages[0] );
+ $parts = $messages[0]->getParts();
+ $this->assertCount( 3, $parts );
+ $this->assertEquals( 'Part 1', $parts[0]->getText() );
+ $this->assertEquals( 'Part 2', $parts[1]->getText() );
+ $this->assertEquals( 'Part 3', $parts[2]->getText() );
+ }
+
+ /**
+ * Tests constructor with mixed parts list.
+ *
+ * @ticket TBD
+ */
+ public function test_constructor_with_mixed_parts_list() {
+ $part1 = new MessagePart( 'Part 1' );
+ $part2_array = array(
+ 'type' => 'text',
+ 'text' => 'Part 2',
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'String part', $part1, $part2_array ) );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+
+ $this->assertCount( 1, $messages );
+ $parts = $messages[0]->getParts();
+ $this->assertCount( 3, $parts );
+ $this->assertEquals( 'String part', $parts[0]->getText() );
+ $this->assertEquals( 'Part 1', $parts[1]->getText() );
+ $this->assertEquals( 'Part 2', $parts[2]->getText() );
+ }
+
+ /**
+ * Tests full method chaining.
+ *
+ * @ticket TBD
+ */
+ public function test_method_chaining() {
+ $model = $this->createMock( ModelInterface::class );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder
+ ->with_text( 'Start of prompt' )
+ ->with_file( 'https://example.com/img.jpg', 'image/jpeg' )
+ ->using_model( $model )
+ ->using_system_instruction( 'Be helpful' )
+ ->using_max_tokens( 500 )
+ ->using_temperature( 0.8 )
+ ->using_top_p( 0.95 )
+ ->using_top_k( 50 )
+ ->using_candidate_count( 2 )
+ ->as_json_response();
+
+ $this->assertSame( $builder, $result );
+
+ /** @var list $messages */
+ $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' );
+ $this->assertCount( 1, $messages );
+ $this->assertCount( 2, $messages[0]->getParts() );
+
+ /** @var ModelInterface $actual_model */
+ $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' );
+ $this->assertSame( $model, $actual_model );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'Be helpful', $config->getSystemInstruction() );
+ $this->assertEquals( 500, $config->getMaxTokens() );
+ $this->assertEquals( 0.8, $config->getTemperature() );
+ $this->assertEquals( 0.95, $config->getTopP() );
+ $this->assertEquals( 50, $config->getTopK() );
+ $this->assertEquals( 2, $config->getCandidateCount() );
+ $this->assertEquals( 'application/json', $config->getOutputMimeType() );
+ }
+
+ /**
+ * Tests usingModelPreference skips unavailable model IDs and falls back.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_skips_unavailable_model_id() {
+ $result = $this->create_test_result( 'Fallback model result' );
+ $other_metadata = $this->create_text_model_metadata_with_input_support( 'other-id' );
+ $fallback_metadata = $this->create_text_model_metadata_with_input_support( 'fallback-id' );
+ $model = $this->create_mock_text_generation_model( $result, $fallback_metadata );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'getProviderId' )
+ ->with( 'test-provider' )
+ ->willReturn( 'test-provider' );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'findProviderModelsMetadataForSupport' )
+ ->with( 'test-provider', $this->isInstanceOf( ModelRequirements::class ) )
+ ->willReturn( array( $other_metadata, $fallback_metadata ) );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'getProviderModel' )
+ ->with( 'test-provider', 'fallback-id', $this->isInstanceOf( ModelConfig::class ) )
+ ->willReturn( $model );
+
+ $this->registry->expects( $this->never() )
+ ->method( 'findModelsMetadataForSupport' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_provider( 'test-provider' );
+ $builder->using_model_preference( 'missing-id', 'fallback-id' );
+
+ $actual_result = $builder->generate_text_result();
+
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests usingModelPreference falls back to discovery when no preferences available.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_falls_back_to_discovery() {
+ $result = $this->create_test_result( 'Discovered model result' );
+ $metadata = $this->create_text_model_metadata_with_input_support( 'discovered-id' );
+ $provider_metadata = $this->create_test_provider_metadata();
+ $provider_models_metadata = new ProviderModelsMetadata( $provider_metadata, array( $metadata ) );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'findModelsMetadataForSupport' )
+ ->with( $this->isInstanceOf( ModelRequirements::class ) )
+ ->willReturn( array( $provider_models_metadata ) );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'getProviderModel' )
+ ->with( $provider_metadata->getId(), 'discovered-id', $this->isInstanceOf( ModelConfig::class ) )
+ ->willReturn( $model );
+
+ $this->registry->expects( $this->never() )
+ ->method( 'findProviderModelsMetadataForSupport' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model_preference( 'unavailable-model' );
+
+ $actual_result = $builder->generate_text_result();
+
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests usingModelPreference respects priority order when multiple preferred models are available.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_respects_order_when_multiple_available() {
+ $result = $this->create_test_result( 'Second choice result' );
+ $second_choice_metadata = $this->create_text_model_metadata_with_input_support( 'second-choice' );
+ $third_choice_metadata = $this->create_text_model_metadata_with_input_support( 'third-choice' );
+ $provider_metadata = $this->create_test_provider_metadata();
+
+ $model = $this->create_mock_text_generation_model( $result, $second_choice_metadata );
+
+ $provider_models_metadata = new ProviderModelsMetadata(
+ $provider_metadata,
+ array( $third_choice_metadata, $second_choice_metadata )
+ );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'findModelsMetadataForSupport' )
+ ->with( $this->isInstanceOf( ModelRequirements::class ) )
+ ->willReturn( array( $provider_models_metadata ) );
+
+ $this->registry->expects( $this->once() )
+ ->method( 'getProviderModel' )
+ ->with( $provider_metadata->getId(), 'second-choice', $this->isInstanceOf( ModelConfig::class ) )
+ ->willReturn( $model );
+
+ $this->registry->expects( $this->never() )
+ ->method( 'findProviderModelsMetadataForSupport' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model_preference( 'first-choice', 'second-choice', 'third-choice' );
+
+ $actual_result = $builder->generate_text_result();
+
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests usingModelPreference rejects invalid preference types, returning WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_with_invalid_type_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $builder->using_model_preference( 123 );
+ $result = $builder->generate_text_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString(
+ 'Model preferences must be model identifiers',
+ $result->get_error_message()
+ );
+ }
+
+ /**
+ * Tests usingModelPreference rejects malformed preference tuples, returning WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_with_invalid_tuple_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $builder->using_model_preference(
+ array(
+ 'provider' => 'test',
+ 'model' => 'id',
+ )
+ );
+ $result = $builder->generate_text_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString(
+ 'Model preference tuple must contain model identifier and provider ID.',
+ $result->get_error_message()
+ );
+ }
+
+ /**
+ * Tests usingModelPreference rejects empty preference identifiers, returning WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_with_empty_identifier_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $builder->using_model_preference( ' ' );
+ $result = $builder->generate_text_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString(
+ 'Model preference identifiers cannot be empty.',
+ $result->get_error_message()
+ );
+ }
+
+ /**
+ * Tests usingModelPreference rejects calls without preferences, returning WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_preference_without_arguments_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $builder->using_model_preference();
+ $result = $builder->generate_text_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString(
+ 'At least one model preference must be provided.',
+ $result->get_error_message()
+ );
+ }
+
+ /**
+ * Tests usingModelConfig method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_config() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $builder->using_system_instruction( 'Builder instruction' )
+ ->using_max_tokens( 500 )
+ ->using_temperature( 0.5 );
+
+ $config = new ModelConfig();
+ $config->setSystemInstruction( 'Config instruction' );
+ $config->setMaxTokens( 1000 );
+ $config->setTopP( 0.9 );
+ $config->setTopK( 40 );
+
+ $result = $builder->using_model_config( $config );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $merged_config */
+ $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'Builder instruction', $merged_config->getSystemInstruction() );
+ $this->assertEquals( 500, $merged_config->getMaxTokens() );
+ $this->assertEquals( 0.5, $merged_config->getTemperature() );
+ $this->assertEquals( 0.9, $merged_config->getTopP() );
+ $this->assertEquals( 40, $merged_config->getTopK() );
+ }
+
+ /**
+ * Tests usingModelConfig with custom options.
+ *
+ * @ticket TBD
+ */
+ public function test_using_model_config_with_custom_options() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $config = new ModelConfig();
+ $config->setCustomOption( 'stopSequences', array( 'CONFIG_STOP' ) );
+ $config->setCustomOption( 'otherOption', 'value' );
+
+ $builder->using_model_config( $config );
+
+ /** @var ModelConfig $merged_config */
+ $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+ $custom_options = $merged_config->getCustomOptions();
+
+ $this->assertArrayHasKey( 'stopSequences', $custom_options );
+ $this->assertIsArray( $custom_options['stopSequences'] );
+ $this->assertEquals( array( 'CONFIG_STOP' ), $custom_options['stopSequences'] );
+ $this->assertArrayHasKey( 'otherOption', $custom_options );
+ $this->assertEquals( 'value', $custom_options['otherOption'] );
+
+ $builder->using_stop_sequences( 'STOP' );
+
+ /** @var ModelConfig $merged_config */
+ $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+ $custom_options = $merged_config->getCustomOptions();
+
+ $this->assertArrayHasKey( 'stopSequences', $custom_options );
+ $this->assertIsArray( $custom_options['stopSequences'] );
+ $this->assertEquals( array( 'STOP' ), $custom_options['stopSequences'] );
+ $this->assertArrayHasKey( 'otherOption', $custom_options );
+ $this->assertEquals( 'value', $custom_options['otherOption'] );
+ }
+
+ /**
+ * Tests usingProvider method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_provider() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_provider( 'test-provider' );
+
+ $this->assertSame( $builder, $result );
+
+ $actual_provider = $this->get_wrapped_prompt_builder_property_value( $builder, 'providerIdOrClassName' );
+ $this->assertEquals( 'test-provider', $actual_provider );
+ }
+
+ /**
+ * Tests usingSystemInstruction method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_system_instruction() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_system_instruction( 'You are a helpful assistant.' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'You are a helpful assistant.', $config->getSystemInstruction() );
+ }
+
+ /**
+ * Tests usingMaxTokens method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_max_tokens() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_max_tokens( 1000 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 1000, $config->getMaxTokens() );
+ }
+
+ /**
+ * Tests usingTemperature method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_temperature() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_temperature( 0.7 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 0.7, $config->getTemperature() );
+ }
+
+ /**
+ * Tests usingTopP method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_top_p() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_top_p( 0.9 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 0.9, $config->getTopP() );
+ }
+
+ /**
+ * Tests usingTopK method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_top_k() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_top_k( 40 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 40, $config->getTopK() );
+ }
+
+ /**
+ * Tests usingStopSequences method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_stop_sequences() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_stop_sequences( 'STOP', 'END', '###' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $custom_options = $config->getCustomOptions();
+ $this->assertArrayHasKey( 'stopSequences', $custom_options );
+ $this->assertEquals( array( 'STOP', 'END', '###' ), $custom_options['stopSequences'] );
+ }
+
+ /**
+ * Tests usingCandidateCount method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_candidate_count() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_candidate_count( 3 );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 3, $config->getCandidateCount() );
+ }
+
+ /**
+ * Tests asOutputMimeType method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_output_mime() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_mime_type( 'application/json' );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'application/json', $config->getOutputMimeType() );
+ }
+
+ /**
+ * Tests asOutputSchema method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_output_schema() {
+ $schema = array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array( 'type' => 'string' ),
+ ),
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_schema( $schema );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( $schema, $config->getOutputSchema() );
+ }
+
+ /**
+ * Tests asOutputModalities method.
+ *
+ * @ticket TBD
+ */
+ public function test_using_output_modalities() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_output_modalities(
+ ModalityEnum::text(),
+ ModalityEnum::image()
+ );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $modalities = $config->getOutputModalities();
+ $this->assertCount( 2, $modalities );
+ $this->assertTrue( $modalities[0]->isText() );
+ $this->assertTrue( $modalities[1]->isImage() );
+ }
+
+ /**
+ * Tests asJsonResponse method.
+ *
+ * @ticket TBD
+ */
+ public function test_as_json_response() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_json_response();
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'application/json', $config->getOutputMimeType() );
+ }
+
+ /**
+ * Tests asJsonResponse with schema.
+ *
+ * @ticket TBD
+ */
+ public function test_as_json_response_with_schema() {
+ $schema = array( 'type' => 'array' );
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->as_json_response( $schema );
+
+ $this->assertSame( $builder, $result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'application/json', $config->getOutputMimeType() );
+ $this->assertEquals( $schema, $config->getOutputSchema() );
+ }
+
+ /**
+ * Tests validateMessages with empty messages returns WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_validate_messages_empty_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'Cannot generate from an empty prompt', $result->get_error_message() );
+ }
+
+ /**
+ * Tests validateMessages with non-user first message returns WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_validate_messages_non_user_first_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder(
+ $this->registry,
+ array(
+ new ModelMessage( array( new MessagePart( 'Model says hi' ) ) ),
+ new UserMessage( array( new MessagePart( 'User response' ) ) ),
+ )
+ );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'The first message must be from a user role', $result->get_error_message() );
+ }
+
+ /**
+ * Tests validateMessages with non-user last message returns WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_validate_messages_non_user_last_returns_wp_error() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $builder->with_text( 'Initial user message' );
+
+ $builder->with_history(
+ new UserMessage( array( new MessagePart( 'Historical user message' ) ) ),
+ new ModelMessage( array( new MessagePart( 'Historical model response' ) ) )
+ );
+
+ // Manually add a model message as the last message.
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $builder_property = $reflection_class->getProperty( 'builder' );
+
+ $wrapped_builder = $builder_property->getValue( $builder );
+ $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) );
+ $messages_property = $reflection_class2->getProperty( 'messages' );
+
+ $messages = $messages_property->getValue( $wrapped_builder );
+ $messages[] = new ModelMessage( array( new MessagePart( 'Final model message' ) ) );
+ $messages_property->setValue( $wrapped_builder, $messages );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'The last message must be from a user role', $result->get_error_message() );
+ }
+
+ /**
+ * Tests parseMessage with empty string returns WP_Error on termination.
+ *
+ * The SDK constructor throws immediately for empty strings, so the exception
+ * is caught in the constructor and stored.
+ *
+ * @ticket TBD
+ */
+ public function test_parse_message_empty_string_returns_wp_error() {
+ // The empty string exception is thrown by the SDK's PromptBuilder constructor,
+ // which happens before our __call() error handling. We must catch it manually.
+ try {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, ' ' );
+ // If we get here, the SDK didn't throw. Test would need adjusting.
+ $result = $builder->generate_result();
+ $this->assertWPError( $result );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertStringContainsString( 'Cannot create a message from an empty string', $e->getMessage() );
+ }
+ }
+
+ /**
+ * Tests parseMessage with empty array returns WP_Error on termination.
+ *
+ * @ticket TBD
+ */
+ public function test_parse_message_empty_array_returns_wp_error() {
+ try {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array() );
+ $result = $builder->generate_result();
+ $this->assertWPError( $result );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertStringContainsString( 'Cannot create a message from an empty array', $e->getMessage() );
+ }
+ }
+
+ /**
+ * Tests parseMessage with invalid type returns WP_Error on termination.
+ *
+ * @ticket TBD
+ */
+ public function test_parse_message_invalid_type_returns_wp_error() {
+ try {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 123 );
+ $result = $builder->generate_result();
+ $this->assertWPError( $result );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertStringContainsString( 'Input must be a string, MessagePart, MessagePartArrayShape', $e->getMessage() );
+ }
+ }
+
+ /**
+ * Tests generateResult with text output modality.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_with_text_modality() {
+ $result = $this->createMock( GenerativeAiResult::class );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_result();
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests generateResult with image output modality.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_with_image_modality() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array(
+ new Candidate(
+ new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_image_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate an image' );
+ $builder->using_model( $model );
+ $builder->as_output_modalities( ModalityEnum::image() );
+
+ $actual_result = $builder->generate_result();
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests generateResult with audio output modality.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_with_audio_modality() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array(
+ new Candidate(
+ new ModelMessage( array( new MessagePart( new File( 'data:audio/wav;base64,UklGRigE=', 'audio/wav' ) ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_speech_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' );
+ $builder->using_model( $model );
+ $builder->as_output_modalities( ModalityEnum::audio() );
+
+ $actual_result = $builder->generate_result();
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests generateResult with multimodal output.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_with_multimodal_output() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate multimodal' );
+ $builder->using_model( $model );
+ $builder->as_output_modalities( ModalityEnum::text(), ModalityEnum::image() );
+
+ $actual_result = $builder->generate_result();
+ $this->assertSame( $result, $actual_result );
+ }
+
+ /**
+ * Tests generateResult returns WP_Error when model does not support modality.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_returns_wp_error_for_unsupported_modality() {
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->createMock( ModelInterface::class );
+ $model->method( 'metadata' )->willReturn( $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model( $model );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'does not support text generation', $result->get_error_message() );
+ }
+
+ /**
+ * Tests generateResult returns WP_Error for unsupported output modality.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_returns_wp_error_for_unsupported_output_modality() {
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->createMock( ModelInterface::class );
+ $model->method( 'metadata' )->willReturn( $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model( $model );
+ $builder->as_output_modalities( ModalityEnum::video() );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'Output modality "video" is not yet supported', $result->get_error_message() );
+ }
+
+ /**
+ * Tests generateTextResult method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_text_result() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_text_result();
+ $this->assertSame( $result, $actual_result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $modalities = $config->getOutputModalities();
+ $this->assertNotNull( $modalities );
+ $this->assertTrue( $modalities[0]->isText() );
+ }
+
+ /**
+ * Tests generateImageResult method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_image_result() {
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array(
+ new Candidate(
+ new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_image_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_image_result();
+ $this->assertSame( $result, $actual_result );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $modalities = $config->getOutputModalities();
+ $this->assertNotNull( $modalities );
+ $this->assertTrue( $modalities[0]->isImage() );
+ }
+
+ /**
+ * Tests generateText returns WP_Error when no candidates.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_text_returns_wp_error_when_no_candidates() {
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model_with_exception(
+ new RuntimeException( 'No candidates were generated' ),
+ $metadata
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' );
+ $builder->using_model( $model );
+
+ $result = $builder->generate_text();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'No candidates were generated', $result->get_error_message() );
+ }
+
+ /**
+ * Tests generateText returns WP_Error when message has no parts.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_text_returns_wp_error_when_no_parts() {
+ $message = new ModelMessage( array() );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_text();
+
+ $this->assertWPError( $actual_result );
+ $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() );
+ $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() );
+ }
+
+ /**
+ * Tests generateText returns WP_Error when part has no text.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_text_returns_wp_error_when_part_has_no_text() {
+ $file = new File( 'https://example.com/image.jpg', 'image/jpeg' );
+ $message_part = new MessagePart( $file );
+ $message = new ModelMessage( array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_text();
+
+ $this->assertWPError( $actual_result );
+ $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() );
+ $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() );
+ }
+
+ /**
+ * Tests generateTexts method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_texts() {
+ $candidates = array(
+ new Candidate(
+ new ModelMessage( array( new MessagePart( 'Text 1' ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ new Candidate(
+ new ModelMessage( array( new MessagePart( 'Text 2' ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ new Candidate(
+ new ModelMessage( array( new MessagePart( 'Text 3' ) ) ),
+ FinishReasonEnum::stop()
+ ),
+ );
+
+ $result = new GenerativeAiResult(
+ 'test-result-id',
+ $candidates,
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' );
+ $builder->using_model( $model );
+
+ $texts = $builder->generate_texts( 3 );
+
+ $this->assertCount( 3, $texts );
+ $this->assertEquals( 'Text 1', $texts[0] );
+ $this->assertEquals( 'Text 2', $texts[1] );
+ $this->assertEquals( 'Text 3', $texts[2] );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 3, $config->getCandidateCount() );
+ }
+
+ /**
+ * Tests generateTexts returns WP_Error when no text generated.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_texts_returns_wp_error_when_no_text_generated() {
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_generation_model_with_exception(
+ new RuntimeException( 'No text was generated from any candidates' ),
+ $metadata
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' );
+ $builder->using_model( $model );
+
+ $result = $builder->generate_texts();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'No text was generated from any candidates', $result->get_error_message() );
+ }
+
+ /**
+ * Tests generateImage method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_image() {
+ $file = new File( 'https://example.com/generated.jpg', 'image/jpeg' );
+ $message_part = new MessagePart( $file );
+ $message = new ModelMessage( array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_image_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' );
+ $builder->using_model( $model );
+
+ $generated_file = $builder->generate_image();
+ $this->assertSame( $file, $generated_file );
+ }
+
+ /**
+ * Tests generateImage returns WP_Error when no image file.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_image_returns_wp_error_when_no_file() {
+ $message_part = new MessagePart( 'Text instead of image' );
+ $message = new ModelMessage( array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_image_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' );
+ $builder->using_model( $model );
+
+ $actual_result = $builder->generate_image();
+
+ $this->assertWPError( $actual_result );
+ $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() );
+ $this->assertStringContainsString( 'No file content found in first candidate', $actual_result->get_error_message() );
+ }
+
+ /**
+ * Tests generateImages method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_images() {
+ $files = array(
+ new File( 'https://example.com/img1.jpg', 'image/jpeg' ),
+ new File( 'https://example.com/img2.jpg', 'image/jpeg' ),
+ );
+
+ $candidates = array();
+ foreach ( $files as $file ) {
+ $candidates[] = new Candidate(
+ new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ),
+ FinishReasonEnum::stop()
+ );
+ }
+
+ $result = new GenerativeAiResult(
+ 'test-result-id',
+ $candidates,
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_image_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate images' );
+ $builder->using_model( $model );
+
+ $generated_files = $builder->generate_images( 2 );
+
+ $this->assertCount( 2, $generated_files );
+ $this->assertSame( $files[0], $generated_files[0] );
+ $this->assertSame( $files[1], $generated_files[1] );
+ }
+
+ /**
+ * Tests convertTextToSpeech method.
+ *
+ * @ticket TBD
+ */
+ public function test_convert_text_to_speech() {
+ $file = new File( 'https://example.com/audio.mp3', 'audio/mp3' );
+ $message_part = new MessagePart( $file );
+ $message = new Message( MessageRoleEnum::model(), array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_to_speech_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' );
+ $builder->using_model( $model );
+
+ $audio_file = $builder->convert_text_to_speech();
+ $this->assertSame( $file, $audio_file );
+ }
+
+ /**
+ * Tests convertTextToSpeeches method.
+ *
+ * @ticket TBD
+ */
+ public function test_convert_text_to_speeches() {
+ $files = array(
+ new File( 'https://example.com/audio1.mp3', 'audio/mp3' ),
+ new File( 'https://example.com/audio2.mp3', 'audio/mp3' ),
+ );
+
+ $candidates = array();
+ foreach ( $files as $file ) {
+ $candidates[] = new Candidate(
+ new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ),
+ FinishReasonEnum::stop()
+ );
+ }
+
+ $result = new GenerativeAiResult(
+ 'test-result-id',
+ $candidates,
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_text_to_speech_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' );
+ $builder->using_model( $model );
+
+ $audio_files = $builder->convert_text_to_speeches( 2 );
+
+ $this->assertCount( 2, $audio_files );
+ $this->assertSame( $files[0], $audio_files[0] );
+ $this->assertSame( $files[1], $audio_files[1] );
+ }
+
+ /**
+ * Tests generateSpeech method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_speech() {
+ $file = new File( 'https://example.com/speech.mp3', 'audio/mp3' );
+ $message_part = new MessagePart( $file );
+ $message = new Message( MessageRoleEnum::model(), array( $message_part ) );
+ $candidate = new Candidate( $message, FinishReasonEnum::stop() );
+
+ $result = new GenerativeAiResult(
+ 'test-result',
+ array( $candidate ),
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_speech_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' );
+ $builder->using_model( $model );
+
+ $speech_file = $builder->generate_speech();
+ $this->assertSame( $file, $speech_file );
+ }
+
+ /**
+ * Tests generateSpeeches method.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_speeches() {
+ $files = array(
+ new File( 'https://example.com/speech1.mp3', 'audio/mp3' ),
+ new File( 'https://example.com/speech2.mp3', 'audio/mp3' ),
+ new File( 'https://example.com/speech3.mp3', 'audio/mp3' ),
+ );
+
+ $candidates = array();
+ foreach ( $files as $file ) {
+ $candidates[] = new Candidate(
+ new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ),
+ FinishReasonEnum::stop(),
+ 10
+ );
+ }
+
+ $result = new GenerativeAiResult(
+ 'test-result-id',
+ $candidates,
+ new TokenUsage( 100, 50, 150 ),
+ $this->create_test_provider_metadata(),
+ $this->create_test_text_model_metadata()
+ );
+
+ $metadata = $this->createMock( ModelMetadata::class );
+ $metadata->method( 'getId' )->willReturn( 'test-model' );
+
+ $model = $this->create_mock_speech_generation_model( $result, $metadata );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' );
+ $builder->using_model( $model );
+
+ $speech_files = $builder->generate_speeches( 3 );
+
+ $this->assertCount( 3, $speech_files );
+ $this->assertSame( $files[0], $speech_files[0] );
+ $this->assertSame( $files[1], $speech_files[1] );
+ $this->assertSame( $files[2], $speech_files[2] );
+ }
+
+ /**
+ * Tests using_abilities with ability name string.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_string() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities( 'wpaiclienttests/simple' );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 1, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() );
+ $this->assertEquals( 'A simple test ability with no parameters.', $declarations[0]->getDescription() );
+ }
+
+ /**
+ * Tests using_abilities with WP_Ability object.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_wp_ability_object() {
+ $ability = wp_get_ability( 'wpaiclienttests/with-params' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities( $ability );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 1, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[0]->getName() );
+ $this->assertEquals( 'A test ability that accepts parameters.', $declarations[0]->getDescription() );
+
+ $params = $declarations[0]->getParameters();
+ $this->assertNotNull( $params );
+ $this->assertArrayHasKey( 'properties', $params );
+ $this->assertArrayHasKey( 'title', $params['properties'] );
+ }
+
+ /**
+ * Tests using_abilities with multiple abilities.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_multiple_abilities() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities(
+ 'wpaiclienttests/simple',
+ 'wpaiclienttests/with-params',
+ 'wpaiclienttests/returns-error'
+ );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 3, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() );
+ $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() );
+ $this->assertEquals( 'wpab__wpaiclienttests__returns-error', $declarations[2]->getName() );
+ }
+
+ /**
+ * Tests using_abilities skips non-existent abilities.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_skips_nonexistent_abilities() {
+ $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities(
+ 'wpaiclienttests/simple',
+ 'nonexistent/ability',
+ 'wpaiclienttests/with-params'
+ );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 2, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() );
+ $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() );
+ }
+
+ /**
+ * Tests using_abilities with empty arguments returns self.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_no_arguments_returns_self() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities();
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNull( $declarations );
+ }
+
+ /**
+ * Tests using_abilities with mixed strings and WP_Ability objects.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_mixed_types() {
+ $ability = wp_get_ability( 'wpaiclienttests/with-params' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities(
+ 'wpaiclienttests/simple',
+ $ability
+ );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 2, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() );
+ $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() );
+ }
+
+ /**
+ * Tests using_abilities with hyphenated ability name.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_with_hyphenated_name() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder->using_abilities( 'wpaiclienttests/hyphen-test' );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 1, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__hyphen-test', $declarations[0]->getName() );
+ }
+
+ /**
+ * Tests using_abilities can be chained with other methods.
+ *
+ * @ticket TBD
+ */
+ public function test_using_ability_method_chaining() {
+ $builder = new WP_AI_Client_Prompt_Builder( $this->registry );
+ $result = $builder
+ ->with_text( 'Test prompt' )
+ ->using_abilities( 'wpaiclienttests/simple' )
+ ->using_system_instruction( 'You are a helpful assistant' )
+ ->using_max_tokens( 500 );
+
+ $this->assertSame( $builder, $result );
+
+ $declarations = $this->get_function_declarations( $builder );
+
+ $this->assertNotNull( $declarations );
+ $this->assertCount( 1, $declarations );
+ $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() );
+
+ /** @var ModelConfig $config */
+ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' );
+
+ $this->assertEquals( 'You are a helpful assistant', $config->getSystemInstruction() );
+ $this->assertEquals( 500, $config->getMaxTokens() );
+ }
+
+ /**
+ * Tests that is_supported returns false when prevent prompt filter returns true.
+ *
+ * @ticket TBD
+ */
+ public function test_is_supported_returns_false_when_filter_prevents_prompt() {
+ add_filter( 'wp_ai_client_prevent_prompt', '__return_true' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' );
+
+ $this->assertFalse( $builder->is_supported() );
+ }
+
+ /**
+ * Tests that generate_result returns WP_Error when prevent prompt filter returns true.
+ *
+ * @ticket TBD
+ */
+ public function test_generate_result_returns_wp_error_when_filter_prevents_prompt() {
+ add_filter( 'wp_ai_client_prevent_prompt', '__return_true' );
+
+ $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' );
+
+ $result = $builder->generate_result();
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'prompt_prevented', $result->get_error_code() );
+ $this->assertSame( 'Prompt execution was prevented by a filter.', $result->get_error_message() );
+ }
+
+ /**
+ * Tests that prevent prompt filter receives a clone of the builder instance.
+ *
+ * @ticket TBD
+ */
+ public function test_prevent_prompt_filter_receives_cloned_builder_instance() {
+ $captured_builder = null;
+
+ add_filter(
+ 'wp_ai_client_prevent_prompt',
+ static function ( $prevent, $builder ) use ( &$captured_builder ) {
+ $captured_builder = $builder;
+ return $prevent;
+ },
+ 10,
+ 2
+ );
+
+ $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' );
+
+ // 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_Prompt_Builder::class, $captured_builder );
+
+ // Reset and test with generate_result().
+ $captured_builder = null;
+ $builder2 = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' );
+ $builder2->generate_result();
+ $this->assertNotSame( $builder2, $captured_builder, 'Filter should receive a clone, not the same instance' );
+ $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder );
+ }
+
+ /**
+ * Tests that once in error state, subsequent fluent calls return the same instance.
+ *
+ * @ticket TBD
+ */
+ public function test_error_state_fluent_calls_return_same_instance() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ // Trigger an error state by calling a nonexistent method.
+ $prompt_builder->nonexistent_method();
+
+ $result = $prompt_builder->with_text( 'Test' );
+ $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' );
+
+ $result = $prompt_builder->using_max_tokens( 100 );
+ $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' );
+ }
+
+ /**
+ * Tests that support check methods return false when in error state.
+ *
+ * @ticket TBD
+ */
+ public function test_support_check_methods_return_false_in_error_state() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ // Trigger an error state by calling a nonexistent method.
+ $prompt_builder->nonexistent_method();
+
+ $this->assertFalse( $prompt_builder->is_supported(), 'is_supported should return false when in error state' );
+ $this->assertFalse( $prompt_builder->is_supported_for_text_generation(), 'is_supported_for_text_generation should return false when in error state' );
+ }
+
+ /**
+ * Tests that generating methods return WP_Error when in error state.
+ *
+ * @ticket TBD
+ */
+ public function test_generating_methods_return_wp_error_in_error_state() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ // Trigger an error state by calling a nonexistent method.
+ $prompt_builder->nonexistent_method();
+
+ $result = $prompt_builder->generate_text();
+ $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ }
+
+ /**
+ * Tests that exception in terminating method is caught and returned as WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_exception_in_terminating_method_caught_and_returned() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $error = $prompt_builder->generate_text();
+
+ $this->assertWPError( $error, 'generate_text should return WP_Error when exception occurs' );
+ $this->assertSame( 'prompt_builder_error', $error->get_error_code() );
+
+ $error_data = $error->get_error_data();
+ $this->assertIsArray( $error_data );
+ $this->assertArrayHasKey( 'exception_class', $error_data );
+ $this->assertNotEmpty( $error_data['exception_class'] );
+ }
+
+ /**
+ * Tests that exception in chained method is caught and returned by the terminating method as WP_Error.
+ *
+ * @ticket TBD
+ */
+ public function test_exception_in_chained_method_caught_and_returned_by_terminating_method() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $result = $prompt_builder
+ ->with_text( 'Start of prompt' )
+ ->with_file( 'https://example.com/img.jpg', 'image/jpeg' )
+ // Invalid: Only provider and model ID must be given.
+ ->using_model_preference( array( 'test-provider', 'test-model', 'test-version' ) )
+ ->using_system_instruction( 'Be helpful' )
+ ->generate_text();
+
+ $this->assertWPError( $result, 'generate_text should return WP_Error when exception occurs' );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
+ $this->assertSame( 'Model preference tuple must contain model identifier and provider ID.', $result->get_error_message() );
+
+ $error_data = $result->get_error_data();
+ $this->assertIsArray( $error_data );
+ $this->assertArrayHasKey( 'exception_class', $error_data );
+ $this->assertNotEmpty( $error_data['exception_class'] );
+ }
+}
diff --git a/tools/php-ai-client/installer.sh b/tools/php-ai-client/installer.sh
new file mode 100755
index 0000000000000..6e128393406df
--- /dev/null
+++ b/tools/php-ai-client/installer.sh
@@ -0,0 +1,365 @@
+#!/usr/bin/env bash
+#
+# Installer script for bundling wordpress/php-ai-client into WordPress Core.
+#
+# Fetches the package, scopes Http\* dependencies via PHP-Scoper, generates
+# a manual autoloader, and places everything into src/wp-includes/php-ai-client/.
+#
+# Usage:
+# bash tools/php-ai-client/installer.sh --branch=refactor/removes-providers
+# bash tools/php-ai-client/installer.sh --version=1.0.0
+#
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+SCOPER_VERSION="0.18.17"
+SCOPER_URL="https://github.com/humbug/php-scoper/releases/download/${SCOPER_VERSION}/php-scoper.phar"
+GITHUB_REPO="https://github.com/WordPress/php-ai-client.git"
+
+TARGET_DIR="src/wp-includes/php-ai-client"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# ---------------------------------------------------------------------------
+# Parse arguments
+# ---------------------------------------------------------------------------
+
+VERSION=""
+BRANCH=""
+
+for arg in "$@"; do
+ case "$arg" in
+ --version=*)
+ VERSION="${arg#--version=}"
+ ;;
+ --branch=*)
+ BRANCH="${arg#--branch=}"
+ ;;
+ --help|-h)
+ echo "Usage: $0 [--version=X.Y.Z | --branch=BRANCH]"
+ echo ""
+ echo "Options:"
+ echo " --version=X.Y.Z Fetch a specific release version"
+ echo " --branch=BRANCH Fetch from a branch (e.g. refactor/removes-providers)"
+ echo ""
+ echo "Must be run from the WordPress development repository root."
+ exit 0
+ ;;
+ *)
+ echo "Error: Unknown argument: $arg"
+ echo "Run '$0 --help' for usage."
+ exit 1
+ ;;
+ esac
+done
+
+if [ -n "$VERSION" ] && [ -n "$BRANCH" ]; then
+ echo "Error: Cannot specify both --version and --branch."
+ exit 1
+fi
+
+if [ -z "$VERSION" ] && [ -z "$BRANCH" ]; then
+ echo "Error: Must specify either --version=X.Y.Z or --branch=BRANCH."
+ exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# Prerequisites
+# ---------------------------------------------------------------------------
+
+check_command() {
+ if ! command -v "$1" &> /dev/null; then
+ echo "Error: '$1' is required but not found in PATH."
+ exit 1
+ fi
+}
+
+check_command php
+check_command composer
+check_command git
+
+# Verify we're running from the repo root.
+if [ ! -f "wp-cli.yml" ] && [ ! -f "wp-config-sample.php" ] && [ ! -d "src/wp-includes" ]; then
+ echo "Error: This script must be run from the WordPress development repository root."
+ exit 1
+fi
+
+echo "==> Starting php-ai-client installer..."
+
+# ---------------------------------------------------------------------------
+# Temp directory (cleaned on exit)
+# ---------------------------------------------------------------------------
+
+TEMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TEMP_DIR"' EXIT
+
+echo "==> Using temp directory: $TEMP_DIR"
+
+# ---------------------------------------------------------------------------
+# Fetch package
+# ---------------------------------------------------------------------------
+
+if [ -n "$BRANCH" ]; then
+ echo "==> Cloning branch '$BRANCH' from $GITHUB_REPO..."
+ git clone --depth 1 --branch "$BRANCH" "$GITHUB_REPO" "$TEMP_DIR/package"
+ echo "==> Installing Composer dependencies..."
+ composer install --no-dev --no-interaction --working-dir="$TEMP_DIR/package"
+ VENDOR_DIR="$TEMP_DIR/package/vendor"
+ CLIENT_SRC="$TEMP_DIR/package/src"
+else
+ echo "==> Fetching version '$VERSION' via Composer..."
+ mkdir -p "$TEMP_DIR/package"
+ composer init --no-interaction --name="temp/installer" --working-dir="$TEMP_DIR/package"
+ composer require "wordpress/php-ai-client:${VERSION}" --no-dev --no-interaction --working-dir="$TEMP_DIR/package"
+ VENDOR_DIR="$TEMP_DIR/package/vendor"
+ CLIENT_SRC="$VENDOR_DIR/wordpress/php-ai-client/src"
+fi
+
+if [ ! -d "$VENDOR_DIR" ]; then
+ echo "Error: vendor directory not found at $VENDOR_DIR"
+ exit 1
+fi
+
+echo "==> Package fetched successfully."
+
+# ---------------------------------------------------------------------------
+# Clean target directory
+# ---------------------------------------------------------------------------
+
+if [ -d "$TARGET_DIR" ]; then
+ echo "==> Removing existing $TARGET_DIR..."
+ rm -rf "$TARGET_DIR"
+fi
+
+# ---------------------------------------------------------------------------
+# Scope dependencies with PHP-Scoper
+# ---------------------------------------------------------------------------
+
+SCOPER_PHAR="$TEMP_DIR/php-scoper.phar"
+
+echo "==> Downloading PHP-Scoper ${SCOPER_VERSION}..."
+curl -fsSL "$SCOPER_URL" -o "$SCOPER_PHAR"
+chmod +x "$SCOPER_PHAR"
+
+# Copy scoper config into temp dir.
+cp "$SCRIPT_DIR/scoper.inc.php" "$TEMP_DIR/scoper.inc.php"
+
+SCOPED_DIR="$TEMP_DIR/scoped"
+
+echo "==> Running PHP-Scoper..."
+php "$SCOPER_PHAR" add-prefix \
+ --working-dir="$TEMP_DIR/package" \
+ --config="$TEMP_DIR/scoper.inc.php" \
+ --output-dir="$SCOPED_DIR" \
+ --force \
+ --no-interaction
+
+echo "==> Scoping complete."
+
+# ---------------------------------------------------------------------------
+# Reorganize scoped output into namespace-based layout
+# ---------------------------------------------------------------------------
+
+THIRD_PARTY_DIR="$TEMP_DIR/third-party"
+
+echo "==> Reorganizing dependencies..."
+php "$SCRIPT_DIR/reorganize.php" \
+ "$VENDOR_DIR/composer/installed.json" \
+ "$SCOPED_DIR/vendor" \
+ "$THIRD_PARTY_DIR"
+
+echo "==> Reorganization complete."
+
+# ---------------------------------------------------------------------------
+# Copy files to target
+# ---------------------------------------------------------------------------
+
+echo "==> Copying files to $TARGET_DIR..."
+
+mkdir -p "$TARGET_DIR/src"
+mkdir -p "$TARGET_DIR/third-party"
+
+# Copy scoped AI client source.
+# If installed via branch, scoped source is at scoped/src/.
+# If installed via version, scoped source is at scoped/vendor/wordpress/php-ai-client/src/.
+if [ -n "$BRANCH" ]; then
+ cp -R "$SCOPED_DIR/src/." "$TARGET_DIR/src/"
+else
+ cp -R "$SCOPED_DIR/vendor/wordpress/php-ai-client/src/." "$TARGET_DIR/src/"
+fi
+
+# Copy reorganized third-party dependencies.
+cp -R "$THIRD_PARTY_DIR/." "$TARGET_DIR/third-party/"
+
+# Third-party paths to remove (not needed at runtime).
+REMOVE_PATHS=(
+ # Composer plugin (build-time only).
+ "Http/Discovery/Composer"
+
+ # HTTPlug client library (SDK uses PSR-18 directly).
+ "Http/Client"
+
+ # Promise/async support (SDK is synchronous).
+ "Http/Promise"
+
+ # Deprecated discovery classes superseded by Psr18ClientDiscovery / Psr17FactoryDiscovery.
+ "Http/Discovery/HttpClientDiscovery.php"
+ "Http/Discovery/HttpAsyncClientDiscovery.php"
+ "Http/Discovery/MessageFactoryDiscovery.php"
+ "Http/Discovery/UriFactoryDiscovery.php"
+ "Http/Discovery/StreamFactoryDiscovery.php"
+ "Http/Discovery/NotFoundException.php"
+
+ # Convenience wrappers not used by the SDK.
+ "Http/Discovery/Psr17Factory.php"
+ "Http/Discovery/Psr18Client.php"
+
+ # Mock strategy (not in default strategy list).
+ "Http/Discovery/Strategy/MockClientStrategy.php"
+
+ # Server-side PSR-7 interfaces (SDK is client-side only).
+ "Psr/Http/Message/ServerRequestInterface.php"
+ "Psr/Http/Message/ServerRequestFactoryInterface.php"
+ "Psr/Http/Message/UploadedFileInterface.php"
+ "Psr/Http/Message/UploadedFileFactoryInterface.php"
+
+ # PSR-14 interfaces not used by the event dispatcher.
+ "Psr/EventDispatcher/ListenerProviderInterface.php"
+ "Psr/EventDispatcher/StoppableEventInterface.php"
+
+ # PSR-16 cache exception interfaces (never thrown or caught).
+ "Psr/SimpleCache/CacheException.php"
+ "Psr/SimpleCache/InvalidArgumentException.php"
+)
+
+for path in "${REMOVE_PATHS[@]}"; do
+ rm -rf "$TARGET_DIR/third-party/$path"
+done
+
+# ---------------------------------------------------------------------------
+# Generate autoload.php
+# ---------------------------------------------------------------------------
+
+echo "==> Generating autoload.php..."
+
+cat > "$TARGET_DIR/autoload.php" << 'AUTOLOAD_PHP'
+ autoload.php generated."
+
+# ---------------------------------------------------------------------------
+# Validate output
+# ---------------------------------------------------------------------------
+
+echo "==> Validating output..."
+
+ERRORS=0
+
+# Check key directories exist.
+for dir in "$TARGET_DIR/src" "$TARGET_DIR/third-party"; do
+ if [ ! -d "$dir" ]; then
+ echo "Error: Expected directory not found: $dir"
+ ERRORS=$((ERRORS + 1))
+ fi
+done
+
+# Check autoloader exists and has valid syntax.
+if [ ! -f "$TARGET_DIR/autoload.php" ]; then
+ echo "Error: autoload.php not found."
+ ERRORS=$((ERRORS + 1))
+else
+ if ! php -l "$TARGET_DIR/autoload.php" > /dev/null 2>&1; then
+ echo "Error: autoload.php has syntax errors."
+ php -l "$TARGET_DIR/autoload.php"
+ ERRORS=$((ERRORS + 1))
+ fi
+fi
+
+# Check that AiClient.php exists in source.
+if [ ! -f "$TARGET_DIR/src/AiClient.php" ]; then
+ echo "Warning: src/AiClient.php not found. The package structure may differ."
+fi
+
+# Check that Http dependencies are scoped.
+if [ -d "$TARGET_DIR/third-party/Http" ]; then
+ SCOPED_COUNT=$(grep -rl "namespace WordPress\\\\AiClientDependencies\\\\Http" "$TARGET_DIR/third-party/Http/" 2>/dev/null | wc -l | tr -d ' ')
+ if [ "$SCOPED_COUNT" -eq 0 ]; then
+ echo "Warning: No scoped Http\\* namespaces found in third-party/Http/."
+ else
+ echo " Found $SCOPED_COUNT scoped Http\\* files."
+ fi
+fi
+
+# Check that Psr interfaces are scoped.
+if [ -d "$TARGET_DIR/third-party/Psr" ]; then
+ SCOPED_PSR=$(grep -rl "namespace WordPress\\\\AiClientDependencies\\\\Psr" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ')
+ if [ "$SCOPED_PSR" -eq 0 ]; then
+ echo "Warning: No scoped Psr\\* namespaces found in third-party/Psr/."
+ else
+ echo " Found $SCOPED_PSR scoped Psr\\* files."
+ fi
+fi
+
+if [ "$ERRORS" -gt 0 ]; then
+ echo "Error: Validation failed with $ERRORS error(s)."
+ exit 1
+fi
+
+echo "==> Validation passed."
+echo "==> php-ai-client bundled successfully at $TARGET_DIR"
+echo ""
+echo "Next steps:"
+echo " 1. Verify: ls -R $TARGET_DIR"
+echo " 2. Test: php -r \"require '$TARGET_DIR/autoload.php'; var_dump(class_exists('WordPress\\\\AiClient\\\\AiClient'));\""
+echo " 3. Lint: composer lint:errors"
diff --git a/tools/php-ai-client/reorganize.php b/tools/php-ai-client/reorganize.php
new file mode 100644
index 0000000000000..026a95670a1c3
--- /dev/null
+++ b/tools/php-ai-client/reorganize.php
@@ -0,0 +1,176 @@
+
+ *
+ * @package WordPress
+ */
+
+if ( $argc < 4 ) {
+ fwrite( STDERR, "Usage: php reorganize.php \n" );
+ exit( 1 );
+}
+
+$installed_json_path = $argv[1];
+$scoped_vendor_dir = rtrim( $argv[2], '/' );
+$output_dir = rtrim( $argv[3], '/' );
+
+if ( ! file_exists( $installed_json_path ) ) {
+ fwrite( STDERR, "Error: installed.json not found at: $installed_json_path\n" );
+ exit( 1 );
+}
+
+if ( ! is_dir( $scoped_vendor_dir ) ) {
+ fwrite( STDERR, "Error: Scoped vendor directory not found at: $scoped_vendor_dir\n" );
+ exit( 1 );
+}
+
+// ---------------------------------------------------------------------------
+// Parse installed.json (handles Composer v1 and v2 formats).
+// ---------------------------------------------------------------------------
+
+$installed_data = json_decode( file_get_contents( $installed_json_path ), true );
+
+if ( null === $installed_data ) {
+ fwrite( STDERR, "Error: Failed to parse installed.json.\n" );
+ exit( 1 );
+}
+
+// Composer v2 wraps packages in a "packages" key; v1 is a flat array.
+if ( isset( $installed_data['packages'] ) && is_array( $installed_data['packages'] ) ) {
+ $packages = $installed_data['packages'];
+} elseif ( isset( $installed_data[0] ) ) {
+ $packages = $installed_data;
+} else {
+ fwrite( STDERR, "Error: Unrecognized installed.json format.\n" );
+ exit( 1 );
+}
+
+// ---------------------------------------------------------------------------
+// Process each dependency package.
+// ---------------------------------------------------------------------------
+
+$files_autoload = array();
+
+foreach ( $packages as $package ) {
+ $name = $package['name'] ?? '';
+
+ // Skip the AI client package itself.
+ if ( 'wordpress/php-ai-client' === $name ) {
+ continue;
+ }
+
+ // Get PSR-4 autoload mappings.
+ $psr4 = $package['autoload']['psr-4'] ?? array();
+
+ if ( empty( $psr4 ) ) {
+ // Check for PSR-0 as fallback.
+ $psr0 = $package['autoload']['psr-0'] ?? array();
+ if ( ! empty( $psr0 ) ) {
+ fwrite( STDERR, "Warning: Package '$name' uses PSR-0 autoloading (not fully supported). Skipping.\n" );
+ }
+ // Still check for files autoload below.
+ }
+
+ // Collect "files" autoload entries for future use.
+ $files = $package['autoload']['files'] ?? array();
+ if ( ! empty( $files ) ) {
+ foreach ( $files as $file ) {
+ $files_autoload[] = array(
+ 'package' => $name,
+ 'file' => $file,
+ );
+ }
+ }
+
+ // Process PSR-4 mappings.
+ foreach ( $psr4 as $namespace_prefix => $source_dirs ) {
+ // Normalize source_dirs to array.
+ if ( ! is_array( $source_dirs ) ) {
+ $source_dirs = array( $source_dirs );
+ }
+
+ // Convert namespace prefix to directory path.
+ // e.g., "Http\\Client\\" → "Http/Client"
+ $namespace_path = rtrim( str_replace( '\\', '/', $namespace_prefix ), '/' );
+
+ // Determine the source directory in the scoped vendor output.
+ // Composer packages are at vendor/{package-name}/{source-dir}/.
+ foreach ( $source_dirs as $source_dir ) {
+ $source_dir = rtrim( $source_dir, '/' );
+
+ // Build the source path in the scoped vendor directory.
+ $source_path = $scoped_vendor_dir . '/' . $name;
+ if ( '' !== $source_dir ) {
+ $source_path .= '/' . $source_dir;
+ }
+
+ if ( ! is_dir( $source_path ) ) {
+ fwrite( STDERR, "Warning: Source directory not found for '$name' at: $source_path\n" );
+ continue;
+ }
+
+ // Build the target path.
+ $target_path = $output_dir . '/' . $namespace_path;
+
+ // Create target directory.
+ if ( ! is_dir( $target_path ) ) {
+ mkdir( $target_path, 0755, true );
+ }
+
+ // Copy files recursively.
+ copy_directory( $source_path, $target_path );
+
+ echo " Copied: $name ($namespace_prefix) → $namespace_path\n";
+ }
+ }
+}
+
+if ( ! empty( $files_autoload ) ) {
+ fwrite( STDERR, "\nNote: The following packages have 'files' autoload entries that may need manual handling:\n" );
+ foreach ( $files_autoload as $entry ) {
+ fwrite( STDERR, " - {$entry['package']}: {$entry['file']}\n" );
+ }
+}
+
+echo "\nReorganization complete.\n";
+
+// ---------------------------------------------------------------------------
+// Helper functions.
+// ---------------------------------------------------------------------------
+
+/**
+ * Recursively copy a directory.
+ *
+ * @param string $source Source directory path.
+ * @param string $dest Destination directory path.
+ */
+function copy_directory( string $source, string $dest ): void {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $source, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $target = $dest . '/' . $iterator->getSubPathname();
+
+ if ( $item->isDir() ) {
+ if ( ! is_dir( $target ) ) {
+ mkdir( $target, 0755, true );
+ }
+ } else {
+ // Ensure parent directory exists.
+ $parent = dirname( $target );
+ if ( ! is_dir( $parent ) ) {
+ mkdir( $parent, 0755, true );
+ }
+ copy( $item->getPathname(), $target );
+ }
+ }
+}
diff --git a/tools/php-ai-client/scoper.inc.php b/tools/php-ai-client/scoper.inc.php
new file mode 100644
index 0000000000000..f08a4ebaf4f41
--- /dev/null
+++ b/tools/php-ai-client/scoper.inc.php
@@ -0,0 +1,111 @@
+ 'WordPress\\AiClientDependencies',
+
+ 'finders' => array(
+ // Include all PHP files in vendor (dependencies) so their namespaces get scoped.
+ Finder::create()
+ ->files()
+ ->ignoreVCS( true )
+ ->notName( '/LICENSE|.*\\.md|.*\\.dist|Makefile/' )
+ ->exclude( array( 'composer', 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) )
+ ->in( 'vendor' ),
+
+ // Include the AI client source files so `use` statements referencing
+ // scoped dependency namespaces get updated. The AI client's own namespace
+ // is excluded below, so its `namespace` declarations stay unchanged.
+ Finder::create()
+ ->files()
+ ->ignoreVCS( true )
+ ->name( '*.php' )
+ ->in( 'src' ),
+ ),
+
+ 'exclude-namespaces' => array(
+ // The AI client's own namespace must not be scoped.
+ 'WordPress\\AiClient',
+ ),
+
+ 'exclude-files' => array(),
+
+ 'exclude-constants' => array(
+ // Preserve WordPress-compatible constants.
+ '/^ABSPATH$/',
+ '/^WPINC$/',
+ ),
+
+ 'exclude-functions' => array(
+ // polyfills.php defines global functions guarded by function_exists().
+ 'str_starts_with',
+ 'str_ends_with',
+ 'str_contains',
+ 'array_is_list',
+ ),
+
+ 'patchers' => array(
+ /**
+ * Fix php-http/discovery hardcoded class name strings.
+ *
+ * Discovery probes for external HTTP implementations using hardcoded FQCN strings.
+ * These must NOT be prefixed because they reference packages outside our bundle
+ * (e.g., GuzzleHttp\Client, Nyholm\Psr7\Factory\Psr17Factory).
+ */
+ static function ( string $file_path, string $prefix, string $contents ): string {
+ // Only patch php-http/discovery files.
+ if ( false === strpos( $file_path, 'php-http/discovery' ) ) {
+ return $contents;
+ }
+
+ // External package namespaces that Discovery probes for.
+ // These must remain un-prefixed in hardcoded string references.
+ $external_namespaces = array(
+ 'GuzzleHttp',
+ 'Http\\Adapter',
+ 'Http\\Client\\Curl',
+ 'Http\\Client\\Socket',
+ 'Http\\Client\\Buzz',
+ 'Http\\Client\\React',
+ 'Buzz',
+ 'Nyholm',
+ 'Laminas',
+ 'Symfony\\Component\\HttpClient',
+ 'Phalcon\\Http',
+ 'Slim\\Psr7',
+ 'Kriswallsmith',
+ );
+
+ foreach ( $external_namespaces as $ns ) {
+ $escaped_ns = preg_quote( $ns, '/' );
+ $escaped_prefix = preg_quote( $prefix, '/' );
+
+ // Remove prefix from string literals containing these namespaces.
+ // Matches: 'WordPress\AiClientDependencies\GuzzleHttp\...' or "WordPress\AiClientDependencies\GuzzleHttp\..."
+ $contents = preg_replace(
+ '/([\'"])' . $escaped_prefix . '\\\\\\\\' . $escaped_ns . '/',
+ '$1' . $ns,
+ $contents
+ );
+
+ // Also handle double-backslash variants in string concatenation.
+ $contents = preg_replace(
+ '/([\'"])' . $escaped_prefix . '\\\\' . $escaped_ns . '/',
+ '$1' . $ns,
+ $contents
+ );
+ }
+
+ return $contents;
+ },
+ ),
+);