+ *
+ * @author Nicolas Grekas
+ */
+class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface
+{
+ private $requestFactory;
+ private $responseFactory;
+ private $serverRequestFactory;
+ private $streamFactory;
+ private $uploadedFileFactory;
+ private $uriFactory;
+ public function __construct(?RequestFactoryInterface $requestFactory = null, ?ResponseFactoryInterface $responseFactory = null, ?ServerRequestFactoryInterface $serverRequestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?UploadedFileFactoryInterface $uploadedFileFactory = null, ?UriFactoryInterface $uriFactory = null)
+ {
+ $this->requestFactory = $requestFactory;
+ $this->responseFactory = $responseFactory;
+ $this->serverRequestFactory = $serverRequestFactory;
+ $this->streamFactory = $streamFactory;
+ $this->uploadedFileFactory = $uploadedFileFactory;
+ $this->uriFactory = $uriFactory;
+ $this->setFactory($requestFactory);
+ $this->setFactory($responseFactory);
+ $this->setFactory($serverRequestFactory);
+ $this->setFactory($streamFactory);
+ $this->setFactory($uploadedFileFactory);
+ $this->setFactory($uriFactory);
+ }
+ /**
+ * @param UriInterface|string $uri
+ */
+ public function createRequest(string $method, $uri): RequestInterface
+ {
+ $factory = $this->requestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findRequestFactory());
+ return $factory->createRequest(...\func_get_args());
+ }
+ public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
+ {
+ $factory = $this->responseFactory ?? $this->setFactory(Psr17FactoryDiscovery::findResponseFactory());
+ return $factory->createResponse(...\func_get_args());
+ }
+ /**
+ * @param UriInterface|string $uri
+ */
+ public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
+ {
+ $factory = $this->serverRequestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findServerRequestFactory());
+ return $factory->createServerRequest(...\func_get_args());
+ }
+ public function createServerRequestFromGlobals(?array $server = null, ?array $get = null, ?array $post = null, ?array $cookie = null, ?array $files = null, ?StreamInterface $body = null): ServerRequestInterface
+ {
+ $server = $server ?? $_SERVER;
+ $request = $this->createServerRequest($server['REQUEST_METHOD'] ?? 'GET', $this->createUriFromGlobals($server), $server);
+ return $this->buildServerRequestFromGlobals($request, $server, $files ?? $_FILES)->withQueryParams($get ?? $_GET)->withParsedBody($post ?? $_POST)->withCookieParams($cookie ?? $_COOKIE)->withBody($body ?? $this->createStreamFromFile('php://input', 'r+'));
+ }
+ public function createStream(string $content = ''): StreamInterface
+ {
+ $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
+ return $factory->createStream($content);
+ }
+ public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
+ {
+ $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
+ return $factory->createStreamFromFile($filename, $mode);
+ }
+ /**
+ * @param resource $resource
+ */
+ public function createStreamFromResource($resource): StreamInterface
+ {
+ $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
+ return $factory->createStreamFromResource($resource);
+ }
+ public function createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = \UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface
+ {
+ $factory = $this->uploadedFileFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUploadedFileFactory());
+ return $factory->createUploadedFile(...\func_get_args());
+ }
+ public function createUri(string $uri = ''): UriInterface
+ {
+ $factory = $this->uriFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUriFactory());
+ return $factory->createUri(...\func_get_args());
+ }
+ public function createUriFromGlobals(?array $server = null): UriInterface
+ {
+ return $this->buildUriFromGlobals($this->createUri(''), $server ?? $_SERVER);
+ }
+ private function setFactory($factory)
+ {
+ if (!$this->requestFactory && $factory instanceof RequestFactoryInterface) {
+ $this->requestFactory = $factory;
+ }
+ if (!$this->responseFactory && $factory instanceof ResponseFactoryInterface) {
+ $this->responseFactory = $factory;
+ }
+ if (!$this->serverRequestFactory && $factory instanceof ServerRequestFactoryInterface) {
+ $this->serverRequestFactory = $factory;
+ }
+ if (!$this->streamFactory && $factory instanceof StreamFactoryInterface) {
+ $this->streamFactory = $factory;
+ }
+ if (!$this->uploadedFileFactory && $factory instanceof UploadedFileFactoryInterface) {
+ $this->uploadedFileFactory = $factory;
+ }
+ if (!$this->uriFactory && $factory instanceof UriFactoryInterface) {
+ $this->uriFactory = $factory;
+ }
+ return $factory;
+ }
+ private function buildServerRequestFromGlobals(ServerRequestInterface $request, array $server, array $files): ServerRequestInterface
+ {
+ $request = $request->withProtocolVersion(isset($server['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1')->withUploadedFiles($this->normalizeFiles($files));
+ $headers = [];
+ foreach ($server as $k => $v) {
+ if (0 === strpos($k, 'HTTP_')) {
+ $k = substr($k, 5);
+ } elseif (!\in_array($k, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], \true)) {
+ continue;
+ }
+ $k = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $k))));
+ $headers[$k] = $v;
+ }
+ if (!isset($headers['Authorization'])) {
+ if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
+ $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
+ } elseif (isset($_SERVER['PHP_AUTH_USER'])) {
+ $headers['Authorization'] = 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . ($_SERVER['PHP_AUTH_PW'] ?? ''));
+ } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) {
+ $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST'];
+ }
+ }
+ foreach ($headers as $k => $v) {
+ try {
+ $request = $request->withHeader($k, $v);
+ } catch (\InvalidArgumentException $e) {
+ // ignore invalid headers
+ }
+ }
+ return $request;
+ }
+ private function buildUriFromGlobals(UriInterface $uri, array $server): UriInterface
+ {
+ $uri = $uri->withScheme(!empty($server['HTTPS']) && 'off' !== strtolower($server['HTTPS']) ? 'https' : 'http');
+ $hasPort = \false;
+ if (isset($server['HTTP_HOST'])) {
+ $parts = parse_url('http://' . $server['HTTP_HOST']);
+ $uri = $uri->withHost($parts['host'] ?? 'localhost');
+ if ($parts['port'] ?? \false) {
+ $hasPort = \true;
+ $uri = $uri->withPort($parts['port']);
+ }
+ } else {
+ $uri = $uri->withHost($server['SERVER_NAME'] ?? $server['SERVER_ADDR'] ?? 'localhost');
+ }
+ if (!$hasPort && isset($server['SERVER_PORT'])) {
+ $uri = $uri->withPort($server['SERVER_PORT']);
+ }
+ $hasQuery = \false;
+ if (isset($server['REQUEST_URI'])) {
+ $requestUriParts = explode('?', $server['REQUEST_URI'], 2);
+ $uri = $uri->withPath($requestUriParts[0]);
+ if (isset($requestUriParts[1])) {
+ $hasQuery = \true;
+ $uri = $uri->withQuery($requestUriParts[1]);
+ }
+ }
+ if (!$hasQuery && isset($server['QUERY_STRING'])) {
+ $uri = $uri->withQuery($server['QUERY_STRING']);
+ }
+ return $uri;
+ }
+ private function normalizeFiles(array $files): array
+ {
+ foreach ($files as $k => $v) {
+ if ($v instanceof UploadedFileInterface) {
+ continue;
+ }
+ if (!\is_array($v)) {
+ unset($files[$k]);
+ } elseif (!isset($v['tmp_name'])) {
+ $files[$k] = $this->normalizeFiles($v);
+ } else {
+ $files[$k] = $this->createUploadedFileFromSpec($v);
+ }
+ }
+ return $files;
+ }
+ /**
+ * Create and return an UploadedFile instance from a $_FILES specification.
+ *
+ * @param array $value $_FILES struct
+ *
+ * @return UploadedFileInterface|UploadedFileInterface[]
+ */
+ private function createUploadedFileFromSpec(array $value)
+ {
+ if (!is_array($tmpName = $value['tmp_name'])) {
+ $file = is_file($tmpName) ? $this->createStreamFromFile($tmpName, 'r') : $this->createStream();
+ return $this->createUploadedFile($file, $value['size'], $value['error'], $value['name'], $value['type']);
+ }
+ foreach ($tmpName as $k => $v) {
+ $tmpName[$k] = $this->createUploadedFileFromSpec(['tmp_name' => $v, 'size' => $value['size'][$k] ?? null, 'error' => $value['error'][$k] ?? null, 'name' => $value['name'][$k] ?? null, 'type' => $value['type'][$k] ?? null]);
+ }
+ return $tmpName;
+ }
+}
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..d9e5f9cd42f27
--- /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/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
new file mode 100644
index 0000000000000..83ed4ce970631
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
@@ -0,0 +1,40 @@
+
+ */
+class Psr18Client extends Psr17Factory implements ClientInterface
+{
+ private $client;
+ public function __construct(?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null, ?ResponseFactoryInterface $responseFactory = null, ?ServerRequestFactoryInterface $serverRequestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?UploadedFileFactoryInterface $uploadedFileFactory = null, ?UriFactoryInterface $uriFactory = null)
+ {
+ $requestFactory ?? $requestFactory = $client instanceof RequestFactoryInterface ? $client : null;
+ $responseFactory ?? $responseFactory = $client instanceof ResponseFactoryInterface ? $client : null;
+ $serverRequestFactory ?? $serverRequestFactory = $client instanceof ServerRequestFactoryInterface ? $client : null;
+ $streamFactory ?? $streamFactory = $client instanceof StreamFactoryInterface ? $client : null;
+ $uploadedFileFactory ?? $uploadedFileFactory = $client instanceof UploadedFileFactoryInterface ? $client : null;
+ $uriFactory ?? $uriFactory = $client instanceof UriFactoryInterface ? $client : null;
+ parent::__construct($requestFactory, $responseFactory, $serverRequestFactory, $streamFactory, $uploadedFileFactory, $uriFactory);
+ $this->client = $client ?? Psr18ClientDiscovery::find();
+ }
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ return $this->client->sendRequest($request);
+ }
+}
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..9093e74df078b
--- /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..02b3fdbf8a5b8
--- /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..3e5227f6d56ce
--- /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/MockClientStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php
new file mode 100644
index 0000000000000..3c05c3dce8db2
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php
@@ -0,0 +1,22 @@
+
+ */
+final class MockClientStrategy implements DiscoveryStrategy
+{
+ public static function getCandidates($type)
+ {
+ if (is_a(HttpClient::class, $type, \true) || is_a(HttpAsyncClient::class, $type, \true)) {
+ return [['class' => Mock::class, 'condition' => Mock::class]];
+ }
+ return [];
+ }
+}
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/Http/Discovery/StreamFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php
new file mode 100644
index 0000000000000..770dd80b4ae80
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php
@@ -0,0 +1,32 @@
+
+ *
+ * @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery.
+ */
+final class StreamFactoryDiscovery extends ClassDiscovery
+{
+ /**
+ * Finds a Stream Factory.
+ *
+ * @return StreamFactory
+ *
+ * @throws Exception\NotFoundException
+ */
+ public static function find()
+ {
+ try {
+ $streamFactory = static::findOneByType(StreamFactory::class);
+ } catch (DiscoveryFailedException $e) {
+ throw new NotFoundException('No stream factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e);
+ }
+ return static::instantiateClass($streamFactory);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php
new file mode 100644
index 0000000000000..8847fa4942c4d
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php
@@ -0,0 +1,32 @@
+
+ *
+ * @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery.
+ */
+final class UriFactoryDiscovery extends ClassDiscovery
+{
+ /**
+ * Finds a URI Factory.
+ *
+ * @return UriFactory
+ *
+ * @throws Exception\NotFoundException
+ */
+ public static function find()
+ {
+ try {
+ $uriFactory = static::findOneByType(UriFactory::class);
+ } catch (DiscoveryFailedException $e) {
+ throw new NotFoundException('No uri factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e);
+ }
+ return static::instantiateClass($uriFactory);
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php
new file mode 100644
index 0000000000000..663b091a4e57a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php
@@ -0,0 +1,45 @@
+
+ */
+final class FulfilledPromise implements Promise
+{
+ /**
+ * @var mixed
+ */
+ private $result;
+ /**
+ * @param mixed $result
+ */
+ public function __construct($result)
+ {
+ $this->result = $result;
+ }
+ public function then(?callable $onFulfilled = null, ?callable $onRejected = null)
+ {
+ if (null === $onFulfilled) {
+ return $this;
+ }
+ try {
+ return new self($onFulfilled($this->result));
+ } catch (\Exception $e) {
+ return new RejectedPromise($e);
+ }
+ }
+ public function getState()
+ {
+ return Promise::FULFILLED;
+ }
+ public function wait($unwrap = \true)
+ {
+ if ($unwrap) {
+ return $this->result;
+ }
+ return null;
+ }
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php
new file mode 100644
index 0000000000000..8c3dcb452300a
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php
@@ -0,0 +1,64 @@
+
+ * @author Márk Sági-Kazár
+ */
+interface Promise
+{
+ /**
+ * Promise has not been fulfilled or rejected.
+ */
+ const PENDING = 'pending';
+ /**
+ * Promise has been fulfilled.
+ */
+ const FULFILLED = 'fulfilled';
+ /**
+ * Promise has been rejected.
+ */
+ const REJECTED = 'rejected';
+ /**
+ * Adds behavior for when the promise is resolved or rejected (response will be available, or error happens).
+ *
+ * If you do not care about one of the cases, you can set the corresponding callable to null
+ * The callback will be called when the value arrived and never more than once.
+ *
+ * @param callable|null $onFulfilled called when a response will be available
+ * @param callable|null $onRejected called when an exception occurs
+ *
+ * @return Promise a new resolved promise with value of the executed callback (onFulfilled / onRejected)
+ */
+ public function then(?callable $onFulfilled = null, ?callable $onRejected = null);
+ /**
+ * Returns the state of the promise, one of PENDING, FULFILLED or REJECTED.
+ *
+ * @return string
+ */
+ public function getState();
+ /**
+ * Wait for the promise to be fulfilled or rejected.
+ *
+ * When this method returns, the request has been resolved and if callables have been
+ * specified, the appropriate one has terminated.
+ *
+ * When $unwrap is true (the default), the response is returned, or the exception thrown
+ * on failure. Otherwise, nothing is returned or thrown.
+ *
+ * @param bool $unwrap Whether to return resolved value / throw reason or not
+ *
+ * @return ($unwrap is true ? mixed : null) Resolved value, null if $unwrap is set to false
+ *
+ * @throws \Throwable the rejection reason if $unwrap is set to true and the request failed
+ */
+ public function wait($unwrap = \true);
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php
new file mode 100644
index 0000000000000..f1d8e2f9a173c
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php
@@ -0,0 +1,42 @@
+
+ */
+final class RejectedPromise implements Promise
+{
+ /**
+ * @var \Throwable
+ */
+ private $exception;
+ public function __construct(\Throwable $exception)
+ {
+ $this->exception = $exception;
+ }
+ public function then(?callable $onFulfilled = null, ?callable $onRejected = null)
+ {
+ if (null === $onRejected) {
+ return $this;
+ }
+ try {
+ return new FulfilledPromise($onRejected($this->exception));
+ } catch (\Exception $e) {
+ return new self($e);
+ }
+ }
+ public function getState()
+ {
+ return Promise::REJECTED;
+ }
+ public function wait($unwrap = \true)
+ {
+ if ($unwrap) {
+ throw $this->exception;
+ }
+ return null;
+ }
+}
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..d522445fce250
--- /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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\MessageInterface;
+ /**
+ * Gets the body of the message.
+ *
+ * @return StreamInterface Returns the body as a stream.
+ */
+ public function getBody(): \Psr\Http\Message\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(\Psr\Http\Message\StreamInterface $body): \Psr\Http\Message\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..b06c80eb405b0
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php
@@ -0,0 +1,18 @@
+getQuery()`
+ * or from the `QUERY_STRING` server param.
+ *
+ * @return array
+ */
+ public function getQueryParams(): array;
+ /**
+ * Return an instance with the specified query string arguments.
+ *
+ * These values SHOULD remain immutable over the course of the incoming
+ * request. They MAY be injected during instantiation, such as from PHP's
+ * $_GET superglobal, or MAY be derived from some other value such as the
+ * URI. In cases where the arguments are parsed from the URI, the data
+ * MUST be compatible with what PHP's parse_str() would return for
+ * purposes of how duplicate query parameters are handled, and how nested
+ * sets are handled.
+ *
+ * Setting query string arguments MUST NOT change the URI stored by the
+ * request, nor the values in the server params.
+ *
+ * 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
+ * updated query string arguments.
+ *
+ * @param array $query Array of query string arguments, typically from
+ * $_GET.
+ * @return static
+ */
+ public function withQueryParams(array $query): \Psr\Http\Message\ServerRequestInterface;
+ /**
+ * Retrieve normalized file upload data.
+ *
+ * This method returns upload metadata in a normalized tree, with each leaf
+ * an instance of Psr\Http\Message\UploadedFileInterface.
+ *
+ * These values MAY be prepared from $_FILES or the message body during
+ * instantiation, or MAY be injected via withUploadedFiles().
+ *
+ * @return array An array tree of UploadedFileInterface instances; an empty
+ * array MUST be returned if no data is present.
+ */
+ public function getUploadedFiles(): array;
+ /**
+ * Create a new instance with the specified uploaded files.
+ *
+ * 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
+ * updated body parameters.
+ *
+ * @param array $uploadedFiles An array tree of UploadedFileInterface instances.
+ * @return static
+ * @throws \InvalidArgumentException if an invalid structure is provided.
+ */
+ public function withUploadedFiles(array $uploadedFiles): \Psr\Http\Message\ServerRequestInterface;
+ /**
+ * Retrieve any parameters provided in the request body.
+ *
+ * If the request Content-Type is either application/x-www-form-urlencoded
+ * or multipart/form-data, and the request method is POST, this method MUST
+ * return the contents of $_POST.
+ *
+ * Otherwise, this method may return any results of deserializing
+ * the request body content; as parsing returns structured content, the
+ * potential types MUST be arrays or objects only. A null value indicates
+ * the absence of body content.
+ *
+ * @return null|array|object The deserialized body parameters, if any.
+ * These will typically be an array or object.
+ */
+ public function getParsedBody();
+ /**
+ * Return an instance with the specified body parameters.
+ *
+ * These MAY be injected during instantiation.
+ *
+ * If the request Content-Type is either application/x-www-form-urlencoded
+ * or multipart/form-data, and the request method is POST, use this method
+ * ONLY to inject the contents of $_POST.
+ *
+ * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of
+ * deserializing the request body content. Deserialization/parsing returns
+ * structured data, and, as such, this method ONLY accepts arrays or objects,
+ * or a null value if nothing was available to parse.
+ *
+ * As an example, if content negotiation determines that the request data
+ * is a JSON payload, this method could be used to create a request
+ * instance with the deserialized parameters.
+ *
+ * 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
+ * updated body parameters.
+ *
+ * @param null|array|object $data The deserialized body data. This will
+ * typically be in an array or object.
+ * @return static
+ * @throws \InvalidArgumentException if an unsupported argument type is
+ * provided.
+ */
+ public function withParsedBody($data): \Psr\Http\Message\ServerRequestInterface;
+ /**
+ * Retrieve attributes derived from the request.
+ *
+ * The request "attributes" may be used to allow injection of any
+ * parameters derived from the request: e.g., the results of path
+ * match operations; the results of decrypting cookies; the results of
+ * deserializing non-form-encoded message bodies; etc. Attributes
+ * will be application and request specific, and CAN be mutable.
+ *
+ * @return array Attributes derived from the request.
+ */
+ public function getAttributes(): array;
+ /**
+ * Retrieve a single derived request attribute.
+ *
+ * Retrieves a single derived request attribute as described in
+ * getAttributes(). If the attribute has not been previously set, returns
+ * the default value as provided.
+ *
+ * This method obviates the need for a hasAttribute() method, as it allows
+ * specifying a default value to return if the attribute is not found.
+ *
+ * @see getAttributes()
+ * @param string $name The attribute name.
+ * @param mixed $default Default value to return if the attribute does not exist.
+ * @return mixed
+ */
+ public function getAttribute(string $name, $default = null);
+ /**
+ * Return an instance with the specified derived request attribute.
+ *
+ * This method allows setting a single derived request attribute as
+ * described in getAttributes().
+ *
+ * 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
+ * updated attribute.
+ *
+ * @see getAttributes()
+ * @param string $name The attribute name.
+ * @param mixed $value The value of the attribute.
+ * @return static
+ */
+ public function withAttribute(string $name, $value): \Psr\Http\Message\ServerRequestInterface;
+ /**
+ * Return an instance that removes the specified derived request attribute.
+ *
+ * This method allows removing a single derived request attribute as
+ * described in getAttributes().
+ *
+ * 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 attribute.
+ *
+ * @see getAttributes()
+ * @param string $name The attribute name.
+ * @return static
+ */
+ public function withoutAttribute(string $name): \Psr\Http\Message\ServerRequestInterface;
+}
diff --git a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php
new file mode 100644
index 0000000000000..42d3fb70710a9
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php
@@ -0,0 +1,43 @@
+
+ * [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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\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): \Psr\Http\Message\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/CacheException.php b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php
new file mode 100644
index 0000000000000..eba53815c0c98
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php
@@ -0,0 +1,10 @@
+ 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-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php
new file mode 100644
index 0000000000000..7333cb827d27f
--- /dev/null
+++ b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php
@@ -0,0 +1,13 @@
+
Date: Thu, 5 Feb 2026 18:03:04 -0700
Subject: [PATCH 024/147] feat: adds ai client
---
phpunit.xml.dist | 1 +
...wp-ai-client-ability-function-resolver.php | 181 ++
.../class-wp-ai-client-discovery-strategy.php | 90 +
.../class-wp-ai-client-event-dispatcher.php | 82 +
.../class-wp-ai-client-http-client.php | 229 ++
.../class-wp-ai-client-prompt-builder.php | 370 +++
.../class-wp-ai-client-psr17-factory.php | 115 +
.../class-wp-ai-client-psr7-request.php | 384 +++
.../class-wp-ai-client-psr7-response.php | 292 ++
.../class-wp-ai-client-psr7-stream.php | 243 ++
.../ai-client/class-wp-ai-client-psr7-uri.php | 389 +++
src/wp-includes/php-ai-client/autoload.php | 4 +-
src/wp-settings.php | 21 +
.../includes/wp-ai-client-mock-event.php | 17 +
...wp-ai-client-mock-model-creation-trait.php | 445 +++
.../wpAiClientAbilityFunctionResolver.php | 757 ++++++
.../ai-client/wpAiClientEventDispatcher.php | 55 +
.../ai-client/wpAiClientPromptBuilder.php | 2406 +++++++++++++++++
18 files changed, 6079 insertions(+), 2 deletions(-)
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-http-client.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php
create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php
create mode 100644 tests/phpunit/includes/wp-ai-client-mock-event.php
create mode 100644 tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php
create mode 100644 tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php
create mode 100644 tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 4b6c149867c7d..2ba1cf60023df 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -48,6 +48,7 @@
src/wp-includes/PHPMailer
src/wp-includes/Requests
src/wp-includes/php-ai-client
+ src/wp-includes/ai-client
src/wp-includes/SimplePie
src/wp-includes/sodium_compat
src/wp-includes/Text
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
new file mode 100644
index 0000000000000..474314aab498a
--- /dev/null
+++ b/src/wp-includes/ai-client/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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php
new file mode 100644
index 0000000000000..4314609c3a7db
--- /dev/null
+++ b/src/wp-includes/ai-client/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(
+ 'Psr\Http\Message\RequestFactoryInterface',
+ 'Psr\Http\Message\ResponseFactoryInterface',
+ 'Psr\Http\Message\ServerRequestFactoryInterface',
+ 'Psr\Http\Message\StreamFactoryInterface',
+ 'Psr\Http\Message\UploadedFileFactoryInterface',
+ '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 6.8.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/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php
new file mode 100644
index 0000000000000..bfe294ed1d92f
--- /dev/null
+++ b/src/wp-includes/ai-client/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 6.8.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 6.8.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/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client/class-wp-ai-client-http-client.php
new file mode 100644
index 0000000000000..a49324f130a47
--- /dev/null
+++ b/src/wp-includes/ai-client/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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
new file mode 100644
index 0000000000000..e34e15e11936f
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
@@ -0,0 +1,370 @@
+ $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 6.8.0
+ * @var PromptBuilder
+ */
+ private PromptBuilder $builder;
+
+ /**
+ * WordPress error instance, if any error occurred during method calls.
+ *
+ * @since 6.8.0
+ * @var WP_Error|null
+ */
+ private ?WP_Error $error = null;
+
+ /**
+ * List of methods that terminate the fluent interface and return a result.
+ *
+ * Structured as a map for faster lookups.
+ *
+ * @since 6.8.0
+ * @var array
+ */
+ private static array $terminate_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,
+ );
+
+ /**
+ * Constructor.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.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_terminating_method( $name ) ) {
+ return $this->error;
+ }
+ return $this;
+ }
+
+ // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods.
+ if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) {
+ /**
+ * Filters whether to prevent the prompt from being executed.
+ *
+ * @since 6.8.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 ( $this->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_terminating_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_terminating_method( $name ) ) {
+ return $this->error;
+ }
+ return $this;
+ }
+ }
+
+ /**
+ * Checks if a method name is a support check method (is_supported*).
+ *
+ * @since 6.8.0
+ *
+ * @param string $name The method name.
+ * @return bool True if the method is a support check method, false otherwise.
+ */
+ protected function is_support_check_method( string $name ): bool {
+ return str_starts_with( $name, 'is_supported' );
+ }
+
+ /**
+ * Checks if a method name is a generating method (generate_*, convert_text_to_speech*).
+ *
+ * @since 6.8.0
+ *
+ * @param string $name The method name.
+ * @return bool True if the method is a generating method, false otherwise.
+ */
+ protected function is_generating_method( string $name ): bool {
+ return str_starts_with( $name, 'generate_' )
+ || str_starts_with( $name, 'convert_text_to_speech' );
+ }
+
+ /**
+ * Checks if a method is a terminating method.
+ *
+ * @since 6.8.0
+ *
+ * @param string $name The method name.
+ * @return bool True if the method is a terminating method, false otherwise.
+ */
+ private static function is_terminating_method( string $name ): bool {
+ return isset( self::$terminate_methods[ $name ] );
+ }
+
+ /**
+ * Retrieves a callable for a given PHP AI Client SDK prompt builder method name.
+ *
+ * @since 6.8.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 6.8.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/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php
new file mode 100644
index 0000000000000..c9a8f75b9e934
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php
@@ -0,0 +1,115 @@
+}>
+ */
+ private $headers = array();
+
+ /**
+ * Request body.
+ *
+ * @since 6.8.0
+ * @var StreamInterface
+ */
+ private $body;
+
+ /**
+ * Explicit request target, if set.
+ *
+ * @since 6.8.0
+ * @var string|null
+ */
+ private $request_target;
+
+ /**
+ * Constructor.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return string HTTP protocol version.
+ */
+ public function getProtocolVersion(): string {
+ return $this->protocol_version;
+ }
+
+ /**
+ * Returns an instance with the specified HTTP protocol version.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.0
+ *
+ * @return StreamInterface
+ */
+ public function getBody(): StreamInterface {
+ return $this->body;
+ }
+
+ /**
+ * Returns an instance with the specified message body.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.0
+ *
+ * @return string
+ */
+ public function getMethod(): string {
+ return $this->method;
+ }
+
+ /**
+ * Returns an instance with the provided HTTP method.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return UriInterface
+ */
+ public function getUri(): UriInterface {
+ return $this->uri;
+ }
+
+ /**
+ * Returns an instance with the provided URI.
+ *
+ * @since 6.8.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 6.8.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/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php
new file mode 100644
index 0000000000000..fe84a7dc5dfd1
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php
@@ -0,0 +1,292 @@
+}>
+ */
+ private $headers = array();
+
+ /**
+ * Response body.
+ *
+ * @since 6.8.0
+ * @var StreamInterface
+ */
+ private $body;
+
+ /**
+ * Constructor.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.0
+ *
+ * @return string Reason phrase.
+ */
+ public function getReasonPhrase(): string {
+ return $this->reason_phrase;
+ }
+
+ /**
+ * Retrieves the HTTP protocol version.
+ *
+ * @since 6.8.0
+ *
+ * @return string HTTP protocol version.
+ */
+ public function getProtocolVersion(): string {
+ return $this->protocol_version;
+ }
+
+ /**
+ * Returns an instance with the specified HTTP protocol version.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.0
+ *
+ * @return StreamInterface
+ */
+ public function getBody(): StreamInterface {
+ return $this->body;
+ }
+
+ /**
+ * Returns an instance with the specified message body.
+ *
+ * @since 6.8.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/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php
new file mode 100644
index 0000000000000..273b04a8fb669
--- /dev/null
+++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php
@@ -0,0 +1,243 @@
+content = $content;
+ }
+
+ /**
+ * Reads all data from the stream into a string.
+ *
+ * @since 6.8.0
+ *
+ * @return string
+ */
+ public function __toString(): string {
+ return $this->content;
+ }
+
+ /**
+ * Closes the stream. No-op for string-backed streams.
+ *
+ * @since 6.8.0
+ */
+ public function close(): void {
+ // No-op.
+ }
+
+ /**
+ * Separates any underlying resources from the stream.
+ *
+ * @since 6.8.0
+ *
+ * @return resource|null Always null for string-backed streams.
+ */
+ public function detach() {
+ return null;
+ }
+
+ /**
+ * Gets the size of the stream.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return int Position of the pointer.
+ */
+ public function tell(): int {
+ return $this->offset;
+ }
+
+ /**
+ * Returns true if the stream is at the end.
+ *
+ * @since 6.8.0
+ *
+ * @return bool
+ */
+ public function eof(): bool {
+ return $this->offset >= strlen( $this->content );
+ }
+
+ /**
+ * Returns whether the stream is seekable.
+ *
+ * @since 6.8.0
+ *
+ * @return bool Always true.
+ */
+ public function isSeekable(): bool {
+ return true;
+ }
+
+ /**
+ * Seeks to a position in the stream.
+ *
+ * @since 6.8.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 6.8.0
+ */
+ public function rewind(): void {
+ $this->offset = 0;
+ }
+
+ /**
+ * Returns whether the stream is writable.
+ *
+ * @since 6.8.0
+ *
+ * @return bool Always true.
+ */
+ public function isWritable(): bool {
+ return true;
+ }
+
+ /**
+ * Writes data to the stream.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return bool Always true.
+ */
+ public function isReadable(): bool {
+ return true;
+ }
+
+ /**
+ * Reads data from the stream.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return string
+ */
+ public function getContents(): string {
+ $remaining = substr( $this->content, $this->offset );
+ $this->offset = strlen( $this->content );
+
+ return $remaining;
+ }
+
+ /**
+ * Gets stream metadata.
+ *
+ * @since 6.8.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/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php
new file mode 100644
index 0000000000000..8ea0cf4546b7a
--- /dev/null
+++ b/src/wp-includes/ai-client/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 6.8.0
+ * @var string
+ */
+ private $scheme = '';
+
+ /**
+ * URI user info (e.g. "user:password").
+ *
+ * @since 6.8.0
+ * @var string
+ */
+ private $user_info = '';
+
+ /**
+ * URI host.
+ *
+ * @since 6.8.0
+ * @var string
+ */
+ private $host = '';
+
+ /**
+ * URI port.
+ *
+ * @since 6.8.0
+ * @var int|null
+ */
+ private $port;
+
+ /**
+ * URI path.
+ *
+ * @since 6.8.0
+ * @var string
+ */
+ private $path = '';
+
+ /**
+ * URI query string.
+ *
+ * @since 6.8.0
+ * @var string
+ */
+ private $query = '';
+
+ /**
+ * URI fragment.
+ *
+ * @since 6.8.0
+ * @var string
+ */
+ private $fragment = '';
+
+ /**
+ * Constructor.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return string The URI scheme.
+ */
+ public function getScheme(): string {
+ return $this->scheme;
+ }
+
+ /**
+ * Retrieves the authority component of the URI.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return string The URI user information.
+ */
+ public function getUserInfo(): string {
+ return $this->user_info;
+ }
+
+ /**
+ * Retrieves the host component of the URI.
+ *
+ * @since 6.8.0
+ *
+ * @return string The URI host.
+ */
+ public function getHost(): string {
+ return $this->host;
+ }
+
+ /**
+ * Retrieves the port component of the URI.
+ *
+ * @since 6.8.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 6.8.0
+ *
+ * @return string The URI path.
+ */
+ public function getPath(): string {
+ return $this->path;
+ }
+
+ /**
+ * Retrieves the query string of the URI.
+ *
+ * @since 6.8.0
+ *
+ * @return string The URI query string.
+ */
+ public function getQuery(): string {
+ return $this->query;
+ }
+
+ /**
+ * Retrieves the fragment component of the URI.
+ *
+ * @since 6.8.0
+ *
+ * @return string The URI fragment.
+ */
+ public function getFragment(): string {
+ return $this->fragment;
+ }
+
+ /**
+ * Returns an instance with the specified scheme.
+ *
+ * @since 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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 6.8.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/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php
index 89548a78aa737..7cd81ed038277 100644
--- a/src/wp-includes/php-ai-client/autoload.php
+++ b/src/wp-includes/php-ai-client/autoload.php
@@ -17,7 +17,7 @@
static function ( $class_name ) {
// Namespace prefix for the AI client.
$client_prefix = 'WordPress\\AiClient\\';
- $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' )
+ $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' )
// Namespace prefix for scoped dependencies.
$scoped_prefix = 'WordPress\\AiClientDependencies\\';
@@ -27,7 +27,7 @@ static function ( $class_name ) {
$psr_prefixes = array(
'Psr\\Http\\Client\\' => 16,
'Psr\\Http\\Message\\' => 17,
- 'Psr\\EventDispatcher\\' => 21,
+ 'Psr\\EventDispatcher\\' => 20,
'Psr\\SimpleCache\\' => 16,
);
diff --git a/src/wp-settings.php b/src/wp-settings.php
index f7dfd28fbcc93..23153988bee04 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -287,6 +287,27 @@
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/class-wp-ai-client-psr7-stream.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php';
+
+// WP AI Client - HTTP transport and infrastructure.
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-http-client.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php';
+
+// WP AI Client - Prompt builder.
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php';
+require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
+
+// WP AI Client - Initialization.
+WP_AI_Client_Discovery_Strategy::init();
+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..5bef4912db7a7
--- /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/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
new file mode 100644
index 0000000000000..6e7c7aac40953
--- /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/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
new file mode 100644
index 0000000000000..b44417bae77b3
--- /dev/null
+++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
@@ -0,0 +1,2406 @@
+getProperty( 'builder' );
+ $builder_property->setAccessible( true );
+
+ $wrapped_builder = $builder_property->getValue( $builder );
+
+ $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) );
+ $the_property = $reflection_class2->getProperty( $property );
+ $the_property->setAccessible( true );
+
+ 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' );
+ $builder_property->setAccessible( true );
+ $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' );
+ $conversion_method->setAccessible( true );
+
+ 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_method->setAccessible( true );
+
+ $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' );
+ $builder_property->setAccessible( true );
+ $wrapped_builder = $builder_property->getValue( $prompt_builder );
+
+ $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) );
+ $registry_property = $wrapped_builder_reflection->getProperty( 'registry' );
+ $registry_property->setAccessible( true );
+
+ $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' );
+ $builder_property->setAccessible( true );
+ $wrapped_builder = $builder_property->getValue( $builder );
+ $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) );
+ $messages_property = $reflection_class2->getProperty( 'messages' );
+ $messages_property->setAccessible( true );
+
+ $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 );
+
+ // Simulate an error state by directly setting the error property.
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $error_property = $reflection_class->getProperty( 'error' );
+ $error_property->setAccessible( true );
+ $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) );
+
+ $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 terminating methods return WP_Error when in error state.
+ *
+ * @ticket TBD
+ */
+ public function test_terminating_methods_return_wp_error_in_error_state() {
+ $registry = AiClient::defaultRegistry();
+ $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
+
+ $test_error = new WP_Error( 'test_error', 'Test error message' );
+ $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
+ $error_property = $reflection_class->getProperty( 'error' );
+ $error_property->setAccessible( true );
+ $error_property->setValue( $prompt_builder, $test_error );
+
+ $result = $prompt_builder->generate_text();
+ $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' );
+ $this->assertSame( $test_error, $result, 'Should return the same WP_Error instance' );
+ }
+
+ /**
+ * 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'] );
+ }
+}
From 1c07c3ecd1885838600e2ab4ba38c27864730866 Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Fri, 6 Feb 2026 13:05:44 -0700
Subject: [PATCH 025/147] refactor: moves prompt builder and renames directory
---
phpunit.xml.dist | 2 +-
...wp-ai-client-ability-function-resolver.php | 0
.../class-wp-ai-client-discovery-strategy.php | 0
.../class-wp-ai-client-event-dispatcher.php | 0
.../class-wp-ai-client-http-client.php | 0
.../class-wp-ai-client-psr17-factory.php | 0
.../class-wp-ai-client-psr7-request.php | 0
.../class-wp-ai-client-psr7-response.php | 0
.../class-wp-ai-client-psr7-stream.php | 0
.../class-wp-ai-client-psr7-uri.php | 0
.../class-wp-ai-client-prompt-builder.php | 0
src/wp-settings.php | 22 +++++++++----------
12 files changed, 12 insertions(+), 12 deletions(-)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-ability-function-resolver.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-discovery-strategy.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-event-dispatcher.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-http-client.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr17-factory.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-request.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-response.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-stream.php (100%)
rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-uri.php (100%)
rename src/wp-includes/{ai-client => }/class-wp-ai-client-prompt-builder.php (100%)
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 2ba1cf60023df..fa1b8805a91ec 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -48,7 +48,7 @@
src/wp-includes/PHPMailer
src/wp-includes/Requests
src/wp-includes/php-ai-client
- src/wp-includes/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/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-http-client.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php
rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php
diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php
similarity index 100%
rename from src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
rename to src/wp-includes/class-wp-ai-client-prompt-builder.php
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 23153988bee04..5b672b6698c92 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -289,20 +289,20 @@
require ABSPATH . WPINC . '/php-ai-client/autoload.php';
// WP AI Client - PSR-7 implementations.
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-stream.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php';
+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/class-wp-ai-client-http-client.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php';
+require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-http-client.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 - Prompt builder.
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php';
-require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
+// 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';
// WP AI Client - Initialization.
WP_AI_Client_Discovery_Strategy::init();
From 23f1af0cdcf4171831d8741f847849d5d6f3a404 Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Fri, 6 Feb 2026 13:15:17 -0700
Subject: [PATCH 026/147] fix: handles support methods in an error state
---
.../class-wp-ai-client-prompt-builder.php | 61 +++++++++++--------
.../ai-client/wpAiClientPromptBuilder.php | 36 +++++++----
2 files changed, 58 insertions(+), 39 deletions(-)
diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php
index e34e15e11936f..999adeb6e9f90 100644
--- a/src/wp-includes/class-wp-ai-client-prompt-builder.php
+++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php
@@ -32,11 +32,11 @@
* handling instead of exceptions, snake_case method naming, and integration
* with the Abilities API.
*
- * Only the terminate methods will return a WP_Error, to not break the fluent
+ * Only the generating methods will return a WP_Error, to not break the fluent
* interface. As soon as any exception is caught in a chain of method calls,
* the returned instance will be in an error state, and all subsequent method
* calls will be no-ops that just return the same error state instance. Only
- * when a terminate method is called, the WP_Error will be returned.
+ * when a generating method is called, the WP_Error will be returned.
*
* @since 6.8.0
*
@@ -108,14 +108,14 @@ class WP_AI_Client_Prompt_Builder {
private ?WP_Error $error = null;
/**
- * List of methods that terminate the fluent interface and return a result.
+ * List of methods that generate a result from the prompt.
*
* Structured as a map for faster lookups.
*
* @since 6.8.0
* @var array
*/
- private static array $terminate_methods = array(
+ private static array $generating_methods = array(
'generate_result' => true,
'generate_text_result' => true,
'generate_image_result' => true,
@@ -131,6 +131,25 @@ class WP_AI_Client_Prompt_Builder {
'generate_speeches' => true,
);
+ /**
+ * List of methods that check whether the prompt is supported.
+ *
+ * Structured as a map for faster lookups.
+ *
+ * @since 6.8.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.
*
@@ -219,14 +238,17 @@ public function __call( string $name, array $arguments ) {
* or return the same instance for other methods to maintain the fluent interface.
*/
if ( null !== $this->error ) {
- if ( self::is_terminating_method( $name ) ) {
+ 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 ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) {
+ if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) {
/**
* Filters whether to prevent the prompt from being executed.
*
@@ -239,7 +261,7 @@ public function __call( string $name, array $arguments ) {
if ( $prevent ) {
// For is_supported* methods, return false.
- if ( $this->is_support_check_method( $name ) ) {
+ if ( self::is_support_check_method( $name ) ) {
return false;
}
@@ -252,7 +274,7 @@ public function __call( string $name, array $arguments ) {
)
);
- if ( self::is_terminating_method( $name ) ) {
+ if ( self::is_generating_method( $name ) ) {
return $this->error;
}
return $this;
@@ -278,7 +300,7 @@ public function __call( string $name, array $arguments ) {
)
);
- if ( self::is_terminating_method( $name ) ) {
+ if ( self::is_generating_method( $name ) ) {
return $this->error;
}
return $this;
@@ -293,8 +315,8 @@ public function __call( string $name, array $arguments ) {
* @param string $name The method name.
* @return bool True if the method is a support check method, false otherwise.
*/
- protected function is_support_check_method( string $name ): bool {
- return str_starts_with( $name, 'is_supported' );
+ private static function is_support_check_method( string $name ): bool {
+ return isset( self::$support_check_methods[ $name ] );
}
/**
@@ -305,21 +327,8 @@ protected function is_support_check_method( string $name ): bool {
* @param string $name The method name.
* @return bool True if the method is a generating method, false otherwise.
*/
- protected function is_generating_method( string $name ): bool {
- return str_starts_with( $name, 'generate_' )
- || str_starts_with( $name, 'convert_text_to_speech' );
- }
-
- /**
- * Checks if a method is a terminating method.
- *
- * @since 6.8.0
- *
- * @param string $name The method name.
- * @return bool True if the method is a terminating method, false otherwise.
- */
- private static function is_terminating_method( string $name ): bool {
- return isset( self::$terminate_methods[ $name ] );
+ private static function is_generating_method( string $name ): bool {
+ return isset( self::$generating_methods[ $name ] );
}
/**
diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
index b44417bae77b3..971c44d02fb4c 100644
--- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
+++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
@@ -2324,11 +2324,8 @@ public function test_error_state_fluent_calls_return_same_instance() {
$registry = AiClient::defaultRegistry();
$prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
- // Simulate an error state by directly setting the error property.
- $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
- $error_property = $reflection_class->getProperty( 'error' );
- $error_property->setAccessible( true );
- $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) );
+ // 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' );
@@ -2338,23 +2335,36 @@ public function test_error_state_fluent_calls_return_same_instance() {
}
/**
- * Tests that terminating methods return WP_Error when in error state.
+ * Tests that support check methods return false when in error state.
*
* @ticket TBD
*/
- public function test_terminating_methods_return_wp_error_in_error_state() {
+ public function test_support_check_methods_return_false_in_error_state() {
$registry = AiClient::defaultRegistry();
$prompt_builder = new WP_AI_Client_Prompt_Builder( $registry );
- $test_error = new WP_Error( 'test_error', 'Test error message' );
- $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
- $error_property = $reflection_class->getProperty( 'error' );
- $error_property->setAccessible( true );
- $error_property->setValue( $prompt_builder, $test_error );
+ // 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( $test_error, $result, 'Should return the same WP_Error instance' );
+ $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
}
/**
From 42197b59265ef4dc3223ee07816774f6079f3e9a Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Fri, 6 Feb 2026 14:28:13 -0700
Subject: [PATCH 027/147] refactor: namespaces PSR classes and corrects
versions
---
...wp-ai-client-ability-function-resolver.php | 18 +++---
.../class-wp-ai-client-discovery-strategy.php | 24 ++++----
.../class-wp-ai-client-event-dispatcher.php | 12 ++--
.../class-wp-ai-client-http-client.php | 32 +++++-----
.../class-wp-ai-client-psr17-factory.php | 32 +++++-----
.../class-wp-ai-client-psr7-request.php | 60 +++++++++----------
.../class-wp-ai-client-psr7-response.php | 48 +++++++--------
.../class-wp-ai-client-psr7-stream.php | 42 ++++++-------
.../class-wp-ai-client-psr7-uri.php | 58 +++++++++---------
.../class-wp-ai-client-prompt-builder.php | 30 +++++-----
src/wp-includes/php-ai-client/autoload.php | 24 +-------
.../php-ai-client/src/AiClient.php | 4 +-
.../src/Builders/PromptBuilder.php | 2 +-
.../Contracts/ClientWithOptionsInterface.php | 4 +-
.../src/Providers/Http/DTO/Request.php | 2 +-
.../Http/Exception/NetworkException.php | 2 +-
.../src/Providers/Http/HttpTransporter.php | 14 ++---
.../third-party/Http/Client/Exception.php | 2 +-
.../Http/Client/Exception/HttpException.php | 4 +-
.../Client/Exception/NetworkException.php | 4 +-
.../Client/Exception/RequestAwareTrait.php | 2 +-
.../Client/Exception/RequestException.php | 4 +-
.../Http/Client/HttpAsyncClient.php | 2 +-
.../third-party/Http/Client/HttpClient.php | 2 +-
.../Client/Promise/HttpFulfilledPromise.php | 2 +-
.../Http/Discovery/Composer/Plugin.php | 38 ++++++------
.../Http/Discovery/Psr17Factory.php | 24 ++++----
.../Http/Discovery/Psr17FactoryDiscovery.php | 12 ++--
.../Http/Discovery/Psr18Client.php | 18 +++---
.../Http/Discovery/Psr18ClientDiscovery.php | 2 +-
.../Strategy/CommonClassesStrategy.php | 4 +-
.../Strategy/CommonPsr17ClassesStrategy.php | 12 ++--
.../EventDispatcherInterface.php | 2 +-
.../ListenerProviderInterface.php | 2 +-
.../StoppableEventInterface.php | 2 +-
.../Http/Client/ClientExceptionInterface.php | 2 +-
.../Psr/Http/Client/ClientInterface.php | 6 +-
.../Http/Client/NetworkExceptionInterface.php | 6 +-
.../Http/Client/RequestExceptionInterface.php | 6 +-
.../Psr/Http/Message/MessageInterface.php | 14 ++---
.../Http/Message/RequestFactoryInterface.php | 4 +-
.../Psr/Http/Message/RequestInterface.php | 12 ++--
.../Http/Message/ResponseFactoryInterface.php | 4 +-
.../Psr/Http/Message/ResponseInterface.php | 6 +-
.../Message/ServerRequestFactoryInterface.php | 4 +-
.../Http/Message/ServerRequestInterface.php | 16 ++---
.../Http/Message/StreamFactoryInterface.php | 8 +--
.../Psr/Http/Message/StreamInterface.php | 2 +-
.../Message/UploadedFileFactoryInterface.php | 4 +-
.../Http/Message/UploadedFileInterface.php | 4 +-
.../Psr/Http/Message/UriFactoryInterface.php | 4 +-
.../Psr/Http/Message/UriInterface.php | 16 ++---
.../Psr/SimpleCache/CacheException.php | 2 +-
.../Psr/SimpleCache/CacheInterface.php | 2 +-
.../SimpleCache/InvalidArgumentException.php | 4 +-
.../includes/wp-ai-client-mock-event.php | 2 +-
...wp-ai-client-mock-model-creation-trait.php | 2 +-
tools/php-ai-client/installer.sh | 38 ++++--------
tools/php-ai-client/scoper.inc.php | 18 +-----
59 files changed, 342 insertions(+), 390 deletions(-)
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
index 474314aab498a..e50b86da50165 100644
--- 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
@@ -4,7 +4,7 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
use WordPress\AiClient\Messages\DTO\Message;
@@ -16,14 +16,14 @@
/**
* Resolves and executes WordPress Abilities API function calls from AI models.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_Ability_Function_Resolver {
/**
* Prefix used to identify ability function calls.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private const ABILITY_PREFIX = 'wpab__';
@@ -31,7 +31,7 @@ class WP_AI_Client_Ability_Function_Resolver {
/**
* Checks if a function call is an ability call.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param FunctionCall $call The function call to check.
* @return bool True if the function call is an ability call, false otherwise.
@@ -48,7 +48,7 @@ public static function is_ability_call( FunctionCall $call ): bool {
/**
* Executes a WordPress ability from a function call.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param FunctionCall $call The function call to execute.
* @return FunctionResponse The response from executing the ability.
@@ -107,7 +107,7 @@ public static function execute_ability( FunctionCall $call ): FunctionResponse {
/**
* Checks if a message contains any ability function calls.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param Message $message The message to check.
* @return bool True if the message contains ability calls, false otherwise.
@@ -128,7 +128,7 @@ public static function has_ability_calls( Message $message ): bool {
/**
* Executes all ability function calls in a message.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param Message $message The message containing function calls.
* @return Message A new message with function responses.
@@ -154,7 +154,7 @@ public static function execute_abilities( Message $message ): Message {
*
* Transforms "tec/create_event" to "wpab__tec__create_event".
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $ability_name The ability name to convert.
* @return string The function name.
@@ -168,7 +168,7 @@ public static function ability_name_to_function_name( string $ability_name ): st
*
* Transforms "wpab__tec__create_event" to "tec/create_event".
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $function_name The function name to convert.
* @return string The ability name.
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
index 4314609c3a7db..80bdea4968617 100644
--- 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
@@ -4,12 +4,12 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy;
-use Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* Discovery strategy for WordPress HTTP client.
@@ -17,14 +17,14 @@
* Registers the WordPress HTTP client adapter with the HTTPlug discovery system
* so the AI Client SDK can find and use it automatically.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_Discovery_Strategy implements DiscoveryStrategy {
/**
* Initializes and registers the discovery strategy.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
public static function init() {
if ( ! class_exists( '\WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery' ) ) {
@@ -37,7 +37,7 @@ public static function init() {
/**
* Gets candidates for discovery.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $type The type of discovery.
* @return array> List of candidates.
@@ -54,12 +54,12 @@ public static function getCandidates( $type ) {
}
$psr17_factories = array(
- 'Psr\Http\Message\RequestFactoryInterface',
- 'Psr\Http\Message\ResponseFactoryInterface',
- 'Psr\Http\Message\ServerRequestFactoryInterface',
- 'Psr\Http\Message\StreamFactoryInterface',
- 'Psr\Http\Message\UploadedFileFactoryInterface',
- 'Psr\Http\Message\UriFactoryInterface',
+ '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 ) ) {
@@ -76,7 +76,7 @@ public static function getCandidates( $type ) {
/**
* Creates an instance of the WordPress HTTP client.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return WP_AI_Client_HTTP_Client
*/
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
index bfe294ed1d92f..9eeb85b32a6c0 100644
--- 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
@@ -4,10 +4,10 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\EventDispatcher\EventDispatcherInterface;
+use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
/**
* WordPress-specific PSR-14 event dispatcher for the AI Client.
@@ -15,7 +15,7 @@
* Bridges PSR-14 events to WordPress action hooks, enabling plugins to hook
* into AI client lifecycle events.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface {
@@ -25,7 +25,7 @@ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface {
* Converts the event class name to a WordPress action hook name and fires it.
* For example, BeforeGenerateResultEvent becomes wp_ai_client_before_generate_result.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param object $event The event object to dispatch.
* @return object The same event object, potentially modified by listeners.
@@ -47,7 +47,7 @@ public function dispatch( object $event ): object {
* - wp_ai_client_before_generate_result
* - wp_ai_client_after_generate_result
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param object $event The event object.
*/
@@ -59,7 +59,7 @@ public function dispatch( object $event ): object {
/**
* Converts an event object class name to a WordPress action hook name portion.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param object $event The event object.
* @return string The hook name portion derived from the event class name.
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
index a49324f130a47..bddcde6cf62c0 100644
--- 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
@@ -4,14 +4,14 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Client\ClientInterface;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\Exception\NetworkException;
@@ -22,14 +22,14 @@
* Allows WordPress HTTP functions to be used as a PSR-18 compliant HTTP client
* for the AI Client SDK.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInterface {
/**
* Response factory instance.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var ResponseFactoryInterface
*/
private $response_factory;
@@ -37,7 +37,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte
/**
* Stream factory instance.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var StreamFactoryInterface
*/
private $stream_factory;
@@ -45,7 +45,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param ResponseFactoryInterface $response_factory PSR-17 Response factory.
* @param StreamFactoryInterface $stream_factory PSR-17 Stream factory.
@@ -58,7 +58,7 @@ public function __construct( ResponseFactoryInterface $response_factory, StreamF
/**
* Sends a PSR-7 request and returns a PSR-7 response.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param RequestInterface $request The PSR-7 request.
* @return ResponseInterface The PSR-7 response.
@@ -88,7 +88,7 @@ public function sendRequest( RequestInterface $request ): ResponseInterface {
/**
* Sends a PSR-7 request with transport options and returns a PSR-7 response.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param RequestInterface $request The PSR-7 request.
* @param RequestOptions $options Transport options for the request.
@@ -121,7 +121,7 @@ public function sendRequestWithOptions( RequestInterface $request, RequestOption
/**
* Prepares WordPress HTTP API arguments from a PSR-7 request.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param RequestInterface $request The PSR-7 request.
* @param RequestOptions|null $options Optional transport options for the request.
@@ -152,7 +152,7 @@ private function prepare_wp_args( RequestInterface $request, ?RequestOptions $op
/**
* Prepares headers for WordPress HTTP API.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param RequestInterface $request The PSR-7 request.
* @return array Headers array for WordPress HTTP API.
@@ -174,7 +174,7 @@ private function prepare_headers( RequestInterface $request ): array {
/**
* Prepares request body for WordPress HTTP API.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param RequestInterface $request The PSR-7 request.
* @return string|null The request body.
@@ -196,7 +196,7 @@ private function prepare_body( RequestInterface $request ): ?string {
/**
* Creates a PSR-7 response from a WordPress HTTP response.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param array $wp_response WordPress HTTP API response array.
* @return ResponseInterface PSR-7 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
index c9a8f75b9e934..3f6669d84297c 100644
--- 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
@@ -4,17 +4,17 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-use Psr\Http\Message\StreamInterface;
-use Psr\Http\Message\UriFactoryInterface;
-use Psr\Http\Message\UriInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* Combined PSR-17 factory for creating PSR-7 HTTP message objects.
@@ -22,14 +22,14 @@
* Implements all four PSR-17 factory interfaces, delegating to the minimal
* WP AI Client PSR-7 implementations.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_PSR17_Factory implements RequestFactoryInterface, ResponseFactoryInterface, StreamFactoryInterface, UriFactoryInterface {
/**
* Creates a new request.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $method The HTTP method associated with the request.
* @param UriInterface|string $uri The URI associated with the request.
@@ -42,7 +42,7 @@ public function createRequest( string $method, $uri ): RequestInterface {
/**
* Creates a new response.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $code HTTP status code. Defaults to 200.
* @param string $reasonPhrase Reason phrase to associate with status code.
@@ -55,7 +55,7 @@ public function createResponse( int $code = 200, string $reasonPhrase = '' ): Re
/**
* Creates a new stream from a string.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $content String content with which to populate the stream.
* @return StreamInterface
@@ -67,7 +67,7 @@ public function createStream( string $content = '' ): StreamInterface {
/**
* Creates a stream from an existing file.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $filename Filename or stream URI to use as basis of stream.
* @param string $mode Mode with which to open the underlying filename/stream.
@@ -86,7 +86,7 @@ public function createStreamFromFile( string $filename, string $mode = 'r' ): St
/**
* Creates a new stream from an existing resource.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param resource $resource PHP resource to use as basis of stream.
* @return StreamInterface
@@ -104,7 +104,7 @@ public function createStreamFromResource( $resource ): StreamInterface {
/**
* Creates a new URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $uri The URI string.
* @return UriInterface
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
index 62ca326f67dba..616a394f397ff 100644
--- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
@@ -4,12 +4,12 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\StreamInterface;
-use Psr\Http\Message\UriInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* Minimal PSR-7 HTTP request implementation.
@@ -17,14 +17,14 @@
* Immutable value object representing an outgoing HTTP request for the AI Client
* HTTP transport layer.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* HTTP method.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $method;
@@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* Request URI.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var UriInterface
*/
private $uri;
@@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $protocol_version = '1.1';
@@ -50,7 +50,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
*
* Each value is an array with 'name' (original case) and 'values' (list of strings).
*
- * @since 6.8.0
+ * @since 7.0.0
* @var array}>
*/
private $headers = array();
@@ -58,7 +58,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* Request body.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var StreamInterface
*/
private $body;
@@ -66,7 +66,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* Explicit request target, if set.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string|null
*/
private $request_target;
@@ -74,7 +74,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface {
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $method HTTP method.
* @param string|UriInterface $uri Request URI.
@@ -93,7 +93,7 @@ public function __construct( string $method, $uri ) {
/**
* Retrieves the HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string HTTP protocol version.
*/
@@ -104,7 +104,7 @@ public function getProtocolVersion(): string {
/**
* Returns an instance with the specified HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $version HTTP protocol version.
* @return static
@@ -119,7 +119,7 @@ public function withProtocolVersion( string $version ): self {
/**
* Retrieves all message header values.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string[][] Associative array of headers.
*/
@@ -136,7 +136,7 @@ public function getHeaders(): array {
/**
* Checks if a header exists by the given case-insensitive name.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return bool
@@ -148,7 +148,7 @@ public function hasHeader( string $name ): bool {
/**
* Retrieves a message header value by the given case-insensitive name.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return string[] Header values.
@@ -166,7 +166,7 @@ public function getHeader( string $name ): array {
/**
* Retrieves a comma-separated string of the values for a single header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return string
@@ -178,7 +178,7 @@ public function getHeaderLine( string $name ): string {
/**
* Returns an instance with the provided value replacing the specified header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @param string|string[] $value Header value(s).
@@ -194,7 +194,7 @@ public function withHeader( string $name, $value ): self {
/**
* Returns an instance with the specified header appended with the given value.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name to add.
* @param string|string[] $value Header value(s).
@@ -223,7 +223,7 @@ public function withAddedHeader( string $name, $value ): self {
/**
* Returns an instance without the specified header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name to remove.
* @return static
@@ -238,7 +238,7 @@ public function withoutHeader( string $name ): self {
/**
* Gets the body of the message.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return StreamInterface
*/
@@ -249,7 +249,7 @@ public function getBody(): StreamInterface {
/**
* Returns an instance with the specified message body.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param StreamInterface $body Body.
* @return static
@@ -264,7 +264,7 @@ public function withBody( StreamInterface $body ): self {
/**
* Retrieves the message's request target.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string
*/
@@ -291,7 +291,7 @@ public function getRequestTarget(): string {
/**
* Returns an instance with the specific request-target.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $requestTarget Request target.
* @return static
@@ -306,7 +306,7 @@ public function withRequestTarget( string $requestTarget ): self {
/**
* Retrieves the HTTP method of the request.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string
*/
@@ -317,7 +317,7 @@ public function getMethod(): string {
/**
* Returns an instance with the provided HTTP method.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $method Case-sensitive method.
* @return static
@@ -332,7 +332,7 @@ public function withMethod( string $method ): self {
/**
* Retrieves the URI instance.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return UriInterface
*/
@@ -343,7 +343,7 @@ public function getUri(): UriInterface {
/**
* Returns an instance with the provided URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param UriInterface $uri New request URI to use.
* @param bool $preserveHost Preserve the original state of the Host header.
@@ -369,7 +369,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self {
/**
* Sets a header internally (mutating, for use in constructor and clone methods).
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Header name.
* @param string|string[] $value Header value(s).
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
index fe84a7dc5dfd1..35c3bba303759 100644
--- 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
@@ -4,11 +4,11 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\StreamInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
/**
* Minimal PSR-7 HTTP response implementation.
@@ -16,14 +16,14 @@
* Immutable value object representing an incoming HTTP response for the AI Client
* HTTP transport layer.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_PSR7_Response implements ResponseInterface {
/**
* HTTP status code.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var int
*/
private $status_code;
@@ -31,7 +31,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface {
/**
* Reason phrase associated with the status code.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $reason_phrase;
@@ -39,7 +39,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface {
/**
* HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $protocol_version = '1.1';
@@ -49,7 +49,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface {
*
* Each value is an array with 'name' (original case) and 'values' (list of strings).
*
- * @since 6.8.0
+ * @since 7.0.0
* @var array}>
*/
private $headers = array();
@@ -57,7 +57,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface {
/**
* Response body.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var StreamInterface
*/
private $body;
@@ -65,7 +65,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface {
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $status_code HTTP status code.
* @param string $reason_phrase Reason phrase to associate with the status code.
@@ -79,7 +79,7 @@ public function __construct( int $status_code = 200, string $reason_phrase = ''
/**
* Gets the response status code.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return int Status code.
*/
@@ -90,7 +90,7 @@ public function getStatusCode(): int {
/**
* Returns an instance with the specified status code and reason phrase.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $code The 3-digit integer result code to set.
* @param string $reasonPhrase The reason phrase to use.
@@ -107,7 +107,7 @@ public function withStatus( int $code, string $reasonPhrase = '' ): self {
/**
* Gets the response reason phrase associated with the status code.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string Reason phrase.
*/
@@ -118,7 +118,7 @@ public function getReasonPhrase(): string {
/**
* Retrieves the HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string HTTP protocol version.
*/
@@ -129,7 +129,7 @@ public function getProtocolVersion(): string {
/**
* Returns an instance with the specified HTTP protocol version.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $version HTTP protocol version.
* @return static
@@ -144,7 +144,7 @@ public function withProtocolVersion( string $version ): self {
/**
* Retrieves all message header values.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string[][] Associative array of headers.
*/
@@ -161,7 +161,7 @@ public function getHeaders(): array {
/**
* Checks if a header exists by the given case-insensitive name.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return bool
@@ -173,7 +173,7 @@ public function hasHeader( string $name ): bool {
/**
* Retrieves a message header value by the given case-insensitive name.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return string[] Header values.
@@ -191,7 +191,7 @@ public function getHeader( string $name ): array {
/**
* Retrieves a comma-separated string of the values for a single header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @return string
@@ -203,7 +203,7 @@ public function getHeaderLine( string $name ): string {
/**
* Returns an instance with the provided value replacing the specified header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name.
* @param string|string[] $value Header value(s).
@@ -223,7 +223,7 @@ public function withHeader( string $name, $value ): self {
/**
* Returns an instance with the specified header appended with the given value.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name to add.
* @param string|string[] $value Header value(s).
@@ -252,7 +252,7 @@ public function withAddedHeader( string $name, $value ): self {
/**
* Returns an instance without the specified header.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name Case-insensitive header field name to remove.
* @return static
@@ -267,7 +267,7 @@ public function withoutHeader( string $name ): self {
/**
* Gets the body of the message.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return StreamInterface
*/
@@ -278,7 +278,7 @@ public function getBody(): StreamInterface {
/**
* Returns an instance with the specified message body.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param StreamInterface $body Body.
* @return static
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
index 273b04a8fb669..5ba6395e45754 100644
--- 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
@@ -4,10 +4,10 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Message\StreamInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
/**
* Minimal string-backed PSR-7 stream implementation.
@@ -15,14 +15,14 @@
* Provides the StreamInterface methods needed by the AI Client HTTP transport
* layer without requiring PHP stream resources.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_PSR7_Stream implements StreamInterface {
/**
* The string content of the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $content;
@@ -30,7 +30,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface {
/**
* Current read/write offset position.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var int
*/
private $offset = 0;
@@ -38,7 +38,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface {
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $content Initial content for the stream.
*/
@@ -49,7 +49,7 @@ public function __construct( string $content = '' ) {
/**
* Reads all data from the stream into a string.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string
*/
@@ -60,7 +60,7 @@ public function __toString(): string {
/**
* Closes the stream. No-op for string-backed streams.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
public function close(): void {
// No-op.
@@ -69,7 +69,7 @@ public function close(): void {
/**
* Separates any underlying resources from the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return resource|null Always null for string-backed streams.
*/
@@ -80,7 +80,7 @@ public function detach() {
/**
* Gets the size of the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return int|null The size in bytes.
*/
@@ -91,7 +91,7 @@ public function getSize(): ?int {
/**
* Returns the current position of the read/write pointer.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return int Position of the pointer.
*/
@@ -102,7 +102,7 @@ public function tell(): int {
/**
* Returns true if the stream is at the end.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return bool
*/
@@ -113,7 +113,7 @@ public function eof(): bool {
/**
* Returns whether the stream is seekable.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return bool Always true.
*/
@@ -124,7 +124,7 @@ public function isSeekable(): bool {
/**
* Seeks to a position in the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $offset Stream offset.
* @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END.
@@ -152,7 +152,7 @@ public function seek( int $offset, int $whence = SEEK_SET ): void {
/**
* Seeks to the beginning of the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
public function rewind(): void {
$this->offset = 0;
@@ -161,7 +161,7 @@ public function rewind(): void {
/**
* Returns whether the stream is writable.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return bool Always true.
*/
@@ -172,7 +172,7 @@ public function isWritable(): bool {
/**
* Writes data to the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $string The string to write.
* @return int Number of bytes written.
@@ -188,7 +188,7 @@ public function write( string $string ): int {
/**
* Returns whether the stream is readable.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return bool Always true.
*/
@@ -199,7 +199,7 @@ public function isReadable(): bool {
/**
* Reads data from the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $length Number of bytes to read.
* @return string Data read from the stream.
@@ -214,7 +214,7 @@ public function read( int $length ): string {
/**
* Returns the remaining contents of the stream.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string
*/
@@ -228,7 +228,7 @@ public function getContents(): string {
/**
* Gets stream metadata.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string|null $key Specific metadata to retrieve.
* @return array|mixed|null Returns null for specific keys, empty array otherwise.
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
index 8ea0cf4546b7a..58dfb364d469b 100644
--- 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
@@ -4,24 +4,24 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
-use Psr\Http\Message\UriInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* Minimal PSR-7 URI implementation.
*
* Wraps PHP's parse_url() components into an immutable UriInterface value object.
*
- * @since 6.8.0
+ * @since 7.0.0
*/
class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* Standard ports for HTTP and HTTPS.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var array
*/
private static $default_ports = array(
@@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI scheme (e.g. "http", "https").
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $scheme = '';
@@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI user info (e.g. "user:password").
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $user_info = '';
@@ -48,7 +48,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI host.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $host = '';
@@ -56,7 +56,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI port.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var int|null
*/
private $port;
@@ -64,7 +64,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI path.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $path = '';
@@ -72,7 +72,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI query string.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $query = '';
@@ -80,7 +80,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* URI fragment.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var string
*/
private $fragment = '';
@@ -88,7 +88,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface {
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $uri URI string to parse.
*/
@@ -118,7 +118,7 @@ public function __construct( string $uri = '' ) {
/**
* Retrieves the scheme component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI scheme.
*/
@@ -129,7 +129,7 @@ public function getScheme(): string {
/**
* Retrieves the authority component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI authority, in "[user-info@]host[:port]" format.
*/
@@ -154,7 +154,7 @@ public function getAuthority(): string {
/**
* Retrieves the user information component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI user information.
*/
@@ -165,7 +165,7 @@ public function getUserInfo(): string {
/**
* Retrieves the host component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI host.
*/
@@ -176,7 +176,7 @@ public function getHost(): string {
/**
* Retrieves the port component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return int|null The URI port, or null if standard or not set.
*/
@@ -191,7 +191,7 @@ public function getPort(): ?int {
/**
* Retrieves the path component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI path.
*/
@@ -202,7 +202,7 @@ public function getPath(): string {
/**
* Retrieves the query string of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI query string.
*/
@@ -213,7 +213,7 @@ public function getQuery(): string {
/**
* Retrieves the fragment component of the URI.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string The URI fragment.
*/
@@ -224,7 +224,7 @@ public function getFragment(): string {
/**
* Returns an instance with the specified scheme.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $scheme The scheme to use with the new instance.
* @return static A new instance with the specified scheme.
@@ -239,7 +239,7 @@ public function withScheme( string $scheme ): UriInterface {
/**
* Returns an instance with the specified user information.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $user The user name to use for authority.
* @param string|null $password The password associated with $user.
@@ -259,7 +259,7 @@ public function withUserInfo( string $user, ?string $password = null ): UriInter
/**
* Returns an instance with the specified host.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $host The hostname to use with the new instance.
* @return static A new instance with the specified host.
@@ -274,7 +274,7 @@ public function withHost( string $host ): UriInterface {
/**
* Returns an instance with the specified port.
*
- * @since 6.8.0
+ * @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.
@@ -289,7 +289,7 @@ public function withPort( ?int $port ): UriInterface {
/**
* Returns an instance with the specified path.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $path The path to use with the new instance.
* @return static A new instance with the specified path.
@@ -304,7 +304,7 @@ public function withPath( string $path ): UriInterface {
/**
* Returns an instance with the specified query string.
*
- * @since 6.8.0
+ * @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.
@@ -319,7 +319,7 @@ public function withQuery( string $query ): UriInterface {
/**
* Returns an instance with the specified URI fragment.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $fragment The fragment to use with the new instance.
* @return static A new instance with the specified fragment.
@@ -334,7 +334,7 @@ public function withFragment( string $fragment ): UriInterface {
/**
* Returns the string representation as a URI reference.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return string
*/
@@ -374,7 +374,7 @@ public function __toString(): string {
/**
* Checks whether the current port is the standard port for the scheme.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @return bool True if port is the standard port for the current scheme.
*/
diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php
index 999adeb6e9f90..2a5c2ef53b911 100644
--- a/src/wp-includes/class-wp-ai-client-prompt-builder.php
+++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php
@@ -4,7 +4,7 @@
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
use WordPress\AiClient\Builders\PromptBuilder;
@@ -38,7 +38,7 @@
* calls will be no-ops that just return the same error state instance. Only
* when a generating method is called, the WP_Error will be returned.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @method self with_text(string $text) Adds text to the current message.
* @method self with_file($file, ?string $mimeType = null) Adds a file to the current message.
@@ -94,7 +94,7 @@ class WP_AI_Client_Prompt_Builder {
/**
* Wrapped prompt builder instance from the PHP AI Client SDK.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var PromptBuilder
*/
private PromptBuilder $builder;
@@ -102,7 +102,7 @@ class WP_AI_Client_Prompt_Builder {
/**
* WordPress error instance, if any error occurred during method calls.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var WP_Error|null
*/
private ?WP_Error $error = null;
@@ -112,7 +112,7 @@ class WP_AI_Client_Prompt_Builder {
*
* Structured as a map for faster lookups.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var array
*/
private static array $generating_methods = array(
@@ -136,7 +136,7 @@ class WP_AI_Client_Prompt_Builder {
*
* Structured as a map for faster lookups.
*
- * @since 6.8.0
+ * @since 7.0.0
* @var array
*/
private static array $support_check_methods = array(
@@ -153,7 +153,7 @@ class WP_AI_Client_Prompt_Builder {
/**
* Constructor.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param ProviderRegistry $registry The provider registry for finding suitable models.
* @param mixed $prompt Optional initial prompt content.
@@ -164,7 +164,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) {
/**
* Filters the default request timeout in seconds for AI Client HTTP requests.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param int $default_timeout The default timeout in seconds.
*/
@@ -185,7 +185,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) {
* Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix
* naming convention and passes them to the underlying prompt builder.
*
- * @since 6.8.0
+ * @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.
@@ -226,7 +226,7 @@ public function using_abilities( ...$abilities ): self {
* any exceptions thrown, stores them, and returns a WP_Error when a terminate method
* is called.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name The method name in snake_case.
* @param array $arguments The method arguments.
@@ -252,7 +252,7 @@ public function __call( string $name, array $arguments ) {
/**
* Filters whether to prevent the prompt from being executed.
*
- * @since 6.8.0
+ * @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).
@@ -310,7 +310,7 @@ public function __call( string $name, array $arguments ) {
/**
* Checks if a method name is a support check method (is_supported*).
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name The method name.
* @return bool True if the method is a support check method, false otherwise.
@@ -322,7 +322,7 @@ private static function is_support_check_method( string $name ): bool {
/**
* Checks if a method name is a generating method (generate_*, convert_text_to_speech*).
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name The method name.
* @return bool True if the method is a generating method, false otherwise.
@@ -334,7 +334,7 @@ private static function is_generating_method( string $name ): bool {
/**
* Retrieves a callable for a given PHP AI Client SDK prompt builder method name.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $name The method name in snake_case.
* @return callable The callable for the specified method.
@@ -360,7 +360,7 @@ protected function get_builder_callable( string $name ): callable {
/**
* Converts snake_case to camelCase.
*
- * @since 6.8.0
+ * @since 7.0.0
*
* @param string $snake_case The snake_case string.
* @return string The camelCase string.
diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php
index 7cd81ed038277..b4305ff4c7ed8 100644
--- a/src/wp-includes/php-ai-client/autoload.php
+++ b/src/wp-includes/php-ai-client/autoload.php
@@ -16,21 +16,13 @@
spl_autoload_register(
static function ( $class_name ) {
// Namespace prefix for the AI client.
- $client_prefix = 'WordPress\\AiClient\\';
+ $client_prefix = 'WordPress\\AiClient\\';
$client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' )
- // Namespace prefix for scoped dependencies.
+ // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.).
$scoped_prefix = 'WordPress\\AiClientDependencies\\';
$scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' )
- // PSR interface namespaces (not scoped, kept global).
- $psr_prefixes = array(
- 'Psr\\Http\\Client\\' => 16,
- 'Psr\\Http\\Message\\' => 17,
- 'Psr\\EventDispatcher\\' => 20,
- 'Psr\\SimpleCache\\' => 16,
- );
-
$base_dir = __DIR__;
// 1. WordPress\AiClient\* → src/
@@ -52,17 +44,5 @@ static function ( $class_name ) {
}
return;
}
-
- // 3. Psr\* interfaces → third-party/Psr/...
- foreach ( $psr_prefixes as $prefix => $prefix_len ) {
- if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) {
- $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace.
- $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php';
- if ( file_exists( $file ) ) {
- require $file;
- }
- return;
- }
- }
}
);
diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php
index fb8e1ced1f4d2..f851cfe82d5dc 100644
--- a/src/wp-includes/php-ai-client/src/AiClient.php
+++ b/src/wp-includes/php-ai-client/src/AiClient.php
@@ -3,8 +3,8 @@
declare (strict_types=1);
namespace WordPress\AiClient;
-use Psr\EventDispatcher\EventDispatcherInterface;
-use Psr\SimpleCache\CacheInterface;
+use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
+use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface;
use WordPress\AiClient\Builders\PromptBuilder;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
index d135df56c97fe..6821b99280bd3 100644
--- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
+++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php
@@ -3,7 +3,7 @@
declare (strict_types=1);
namespace WordPress\AiClient\Builders;
-use Psr\EventDispatcher\EventDispatcherInterface;
+use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Events\AfterGenerateResultEvent;
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
index dddfb952a2449..b6a088725f3d5 100644
--- 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
@@ -3,8 +3,8 @@
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
/**
* Interface for HTTP clients that support per-request transport options.
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
index 211daf5ec7acd..8d62f01746632 100644
--- 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
@@ -4,7 +4,7 @@
namespace WordPress\AiClient\Providers\Http\DTO;
use JsonException;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\Collections\HeadersCollection;
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
index 8b4977eb14738..1b26ac2c60f0b 100644
--- 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
@@ -3,7 +3,7 @@
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Request;
/**
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
index 0dc8e56c82a18..dd6cc3e9e4c4b 100644
--- a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php
+++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php
@@ -5,11 +5,11 @@
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
-use Psr\Http\Client\ClientInterface;
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
@@ -75,9 +75,9 @@ public function send(Request $request, ?RequestOptions $options = null): Respons
} else {
$psr7Response = $this->client->sendRequest($psr7Request);
}
- } catch (\Psr\Http\Client\NetworkExceptionInterface $e) {
+ } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) {
throw NetworkException::fromPsr18NetworkException($psr7Request, $e);
- } catch (\Psr\Http\Client\ClientExceptionInterface $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);
}
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php
index f84213a167212..62193c03c9abc 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php
@@ -2,7 +2,7 @@
namespace WordPress\AiClientDependencies\Http\Client;
-use Psr\Http\Client\ClientExceptionInterface as PsrClientException;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface as PsrClientException;
/**
* Every HTTP Client related Exception must implement this interface.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php
index 6e05303eaafc7..fabf0d0486a99 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php
@@ -2,8 +2,8 @@
namespace WordPress\AiClientDependencies\Http\Client\Exception;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
/**
* Thrown when a response was received but the request itself failed.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php
index ece5bdf587362..73bee0c013eea 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php
@@ -2,8 +2,8 @@
namespace WordPress\AiClientDependencies\Http\Client\Exception;
-use Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
/**
* Thrown when the request cannot be completed because of network issues.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php
index fe337b0a34675..dc0c0d60666d8 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php
@@ -2,7 +2,7 @@
namespace WordPress\AiClientDependencies\Http\Client\Exception;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
trait RequestAwareTrait
{
/**
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php
index ec080724b889b..036e6182590ec 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php
@@ -2,8 +2,8 @@
namespace WordPress\AiClientDependencies\Http\Client\Exception;
-use Psr\Http\Client\RequestExceptionInterface as PsrRequestException;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\RequestExceptionInterface as PsrRequestException;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
/**
* Exception for when a request failed, providing access to the failed request.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php
index 4b45bdf90f554..2d7399c385b7e 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php
@@ -3,7 +3,7 @@
namespace WordPress\AiClientDependencies\Http\Client;
use WordPress\AiClientDependencies\Http\Promise\Promise;
-use Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
/**
* Sends a PSR-7 Request in an asynchronous way by returning a Promise.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php
index 244b9ddb7dbc6..5ea57d8c7a735 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php
@@ -2,7 +2,7 @@
namespace WordPress\AiClientDependencies\Http\Client;
-use Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* {@inheritdoc}
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php
index 52a278e32c7f5..be344a4834401 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php
@@ -4,7 +4,7 @@
use WordPress\AiClientDependencies\Http\Client\Exception;
use WordPress\AiClientDependencies\Http\Promise\Promise;
-use Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
final class HttpFulfilledPromise implements Promise
{
/**
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php
index ed28ffc0b06a4..389eede1b5027 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php
@@ -1,24 +1,24 @@
'symfony/framework-bundle', 'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7', 'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6', 'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5', 'php-http/cakephp-adapter' => 'cakephp/cakephp', 'php-http/react-adapter' => 'react/event-loop', 'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1', 'php-http/artax-adapter' => 'amphp/artax:^3', 'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1', 'http-interop/http-factory-slim' => 'slim/slim:^3'];
- private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['Psr\Http\Message\RequestFactoryInterface', 'Psr\Http\Message\ResponseFactoryInterface', 'Psr\Http\Message\ServerRequestFactoryInterface', 'Psr\Http\Message\StreamFactoryInterface', 'Psr\Http\Message\UploadedFileFactoryInterface', 'Psr\Http\Message\UriFactoryInterface']];
+ private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['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']];
public static function getSubscribedEvents(): array
{
return [ScriptEvents::PRE_AUTOLOAD_DUMP => 'preAutoloadDump', ScriptEvents::POST_UPDATE_CMD => 'postUpdate'];
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php
index 561f76b0914b8..2f8880a7111df 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php
@@ -2,18 +2,18 @@
namespace WordPress\AiClientDependencies\Http\Discovery;
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ServerRequestFactoryInterface;
-use Psr\Http\Message\ServerRequestInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-use Psr\Http\Message\StreamInterface;
-use Psr\Http\Message\UploadedFileFactoryInterface;
-use Psr\Http\Message\UploadedFileInterface;
-use Psr\Http\Message\UriFactoryInterface;
-use Psr\Http\Message\UriInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* A generic PSR-17 implementation.
*
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
index d9e5f9cd42f27..5e22ab1dd03c0 100644
--- 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
@@ -4,12 +4,12 @@
use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException;
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ServerRequestFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-use Psr\Http\Message\UploadedFileFactoryInterface;
-use Psr\Http\Message\UriFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface;
/**
* Finds PSR-17 factories.
*
diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
index 83ed4ce970631..55de2592340f3 100644
--- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
+++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
@@ -2,15 +2,15 @@
namespace WordPress\AiClientDependencies\Http\Discovery;
-use Psr\Http\Client\ClientInterface;
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\RequestInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ServerRequestFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-use Psr\Http\Message\UploadedFileFactoryInterface;
-use Psr\Http\Message\UriFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface;
/**
* A generic PSR-18 and PSR-17 implementation.
*
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
index 9093e74df078b..ceca0e4a515b5 100644
--- 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
@@ -4,7 +4,7 @@
use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException;
-use Psr\Http\Client\ClientInterface;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* Finds a PSR-18 HTTP 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
index 02b3fdbf8a5b8..e9c65c8220e93 100644
--- 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
@@ -33,8 +33,8 @@
use WordPress\AiClientDependencies\Http\Message\UriFactory\SlimUriFactory;
use WordPress\AiClientDependencies\Laminas\Diactoros\Request as DiactorosRequest;
use WordPress\AiClientDependencies\Nyholm\Psr7\Factory\HttplugFactory as NyholmHttplugFactory;
-use Psr\Http\Client\ClientInterface as Psr18Client;
-use Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory;
+use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface as Psr18Client;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory;
use WordPress\AiClientDependencies\Slim\Http\Request as SlimRequest;
use WordPress\AiClientDependencies\Symfony\Component\HttpClient\HttplugClient as SymfonyHttplug;
use WordPress\AiClientDependencies\Symfony\Component\HttpClient\Psr18Client as SymfonyPsr18;
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
index 3e5227f6d56ce..7a310542c13c4 100644
--- 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
@@ -2,12 +2,12 @@
namespace WordPress\AiClientDependencies\Http\Discovery\Strategy;
-use Psr\Http\Message\RequestFactoryInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\ServerRequestFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-use Psr\Http\Message\UploadedFileFactoryInterface;
-use Psr\Http\Message\UriFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface;
+use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface;
/**
* @internal
*
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
index d522445fce250..4b85d3d500600 100644
--- 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
@@ -1,7 +1,7 @@
"$TARGET_DIR/autoload.php" << 'AUTOLOAD_PHP'
*
* @package WordPress
* @subpackage AI
- * @since 6.8.0
+ * @since 7.0.0
*/
// Load polyfills (each function is guarded by function_exists).
@@ -219,21 +219,13 @@ require_once __DIR__ . '/src/polyfills.php';
spl_autoload_register(
static function ( $class_name ) {
// Namespace prefix for the AI client.
- $client_prefix = 'WordPress\\AiClient\\';
- $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' )
+ $client_prefix = 'WordPress\\AiClient\\';
+ $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' )
- // Namespace prefix for scoped dependencies.
+ // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.).
$scoped_prefix = 'WordPress\\AiClientDependencies\\';
$scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' )
- // PSR interface namespaces (not scoped, kept global).
- $psr_prefixes = array(
- 'Psr\\Http\\Client\\' => 16,
- 'Psr\\Http\\Message\\' => 17,
- 'Psr\\EventDispatcher\\' => 21,
- 'Psr\\SimpleCache\\' => 16,
- );
-
$base_dir = __DIR__;
// 1. WordPress\AiClient\* → src/
@@ -255,18 +247,6 @@ spl_autoload_register(
}
return;
}
-
- // 3. Psr\* interfaces → third-party/Psr/...
- foreach ( $psr_prefixes as $prefix => $prefix_len ) {
- if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) {
- $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace.
- $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php';
- if ( file_exists( $file ) ) {
- require $file;
- }
- return;
- }
- }
}
);
AUTOLOAD_PHP
@@ -316,10 +296,14 @@ if [ -d "$TARGET_DIR/third-party/Http" ]; then
fi
fi
-# Check that Psr interfaces are NOT scoped.
+# Check that Psr interfaces are scoped.
if [ -d "$TARGET_DIR/third-party/Psr" ]; then
- UNSCOPED_PSR=$(grep -rL "namespace WordPress\\\\AiClientDependencies" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ')
- echo " Found $UNSCOPED_PSR unscoped Psr\\* files."
+ 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
diff --git a/tools/php-ai-client/scoper.inc.php b/tools/php-ai-client/scoper.inc.php
index cbe0428a9909b..f08a4ebaf4f41 100644
--- a/tools/php-ai-client/scoper.inc.php
+++ b/tools/php-ai-client/scoper.inc.php
@@ -2,11 +2,8 @@
/**
* PHP-Scoper configuration for bundling php-ai-client dependencies.
*
- * Scopes Http\* namespaces (php-http packages) to WordPress\AiClientDependencies\Http\*
- * to avoid conflicts with plugin-bundled versions.
- *
- * PSR interfaces (Psr\*) are excluded from scoping so that external HTTP
- * implementations (Guzzle, Nyholm, etc.) remain type-compatible.
+ * Scopes all third-party namespaces (Http\*, Psr\*, etc.) to
+ * WordPress\AiClientDependencies\* to avoid conflicts with plugin-bundled versions.
*
* @package WordPress
*/
@@ -22,7 +19,7 @@
->files()
->ignoreVCS( true )
->notName( '/LICENSE|.*\\.md|.*\\.dist|Makefile/' )
- ->exclude( array( 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) )
+ ->exclude( array( 'composer', 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) )
->in( 'vendor' ),
// Include the AI client source files so `use` statements referencing
@@ -38,15 +35,6 @@
'exclude-namespaces' => array(
// The AI client's own namespace must not be scoped.
'WordPress\\AiClient',
-
- // PSR interfaces stay global for type compatibility with external implementations.
- 'Psr\\Http\\Client',
- 'Psr\\Http\\Message',
- 'Psr\\EventDispatcher',
- 'Psr\\SimpleCache',
-
- // Composer's own namespace.
- 'Composer',
),
'exclude-files' => array(),
From 8a9d2c6cabc75836f968a10f6c285594c2604d24 Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Fri, 6 Feb 2026 17:56:08 -0700
Subject: [PATCH 028/147] feat: adds wp_ai_client_prompt function
---
src/wp-includes/ai-client.php | 22 +++++
src/wp-settings.php | 1 +
.../tests/ai-client/wpAiClientPrompt.php | 92 +++++++++++++++++++
3 files changed, 115 insertions(+)
create mode 100644 src/wp-includes/ai-client.php
create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPrompt.php
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 @@
+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' );
+ $property->setAccessible( true );
+
+ $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' );
+ $builder_property->setAccessible( true );
+ $wrapped = $builder_property->getValue( $builder );
+
+ $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) );
+ $messages_property = $wrapped_reflection->getProperty( 'messages' );
+ $messages_property->setAccessible( true );
+ $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' );
+ $builder_property->setAccessible( true );
+ $wrapped = $builder_property->getValue( $builder );
+
+ $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) );
+ $messages_property = $wrapped_reflection->getProperty( 'messages' );
+ $messages_property->setAccessible( true );
+ $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 );
+ }
+}
From 56c68731f6d45028bfecb1741470a775e33c1c0f Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Fri, 6 Feb 2026 18:01:15 -0700
Subject: [PATCH 029/147] refactor: corrects formatting issues
---
.../class-wp-ai-client-psr7-request.php | 2 +-
.../class-wp-ai-client-psr7-response.php | 6 +++---
.../class-wp-ai-client-prompt-builder.php | 14 +++++++-------
.../tests/ai-client/wpAiClientEventDispatcher.php | 6 +++---
4 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
index 616a394f397ff..1afc8ba87e974 100644
--- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
+++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php
@@ -375,7 +375,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self {
* @param string|string[] $value Header value(s).
*/
private function set_header_internal( string $name, $value ): void {
- $normalized = strtolower( $name );
+ $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
index 35c3bba303759..eb84d2edd73ba 100644
--- 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
@@ -210,9 +210,9 @@ public function getHeaderLine( string $name ): string {
* @return static
*/
public function withHeader( string $name, $value ): self {
- $new = clone $this;
- $normalized = strtolower( $name );
- $new->headers[ $normalized ] = array(
+ $new = clone $this;
+ $normalized = strtolower( $name );
+ $new->headers[ $normalized ] = array(
'name' => $name,
'values' => is_array( $value ) ? $value : array( $value ),
);
diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php
index 2a5c2ef53b911..e4a7c656ffdda 100644
--- a/src/wp-includes/class-wp-ai-client-prompt-builder.php
+++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php
@@ -140,14 +140,14 @@ class WP_AI_Client_Prompt_Builder {
* @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' => 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,
+ 'is_supported_for_video_generation' => true,
+ 'is_supported_for_speech_generation' => true,
+ 'is_supported_for_music_generation' => true,
+ 'is_supported_for_embedding_generation' => true,
);
/**
diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
index 6e7c7aac40953..3cd621f09bf2c 100644
--- a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
+++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php
@@ -19,7 +19,7 @@ public function test_dispatch_fires_action_hook() {
$dispatcher = new WP_AI_Client_Event_Dispatcher();
$event = new WP_AI_Client_Mock_Event();
- $hook_fired = false;
+ $hook_fired = false;
$fired_event = null;
add_action(
@@ -43,8 +43,8 @@ function ( $e ) use ( &$hook_fired, &$fired_event ) {
* @ticket TBD
*/
public function test_dispatch_returns_event_without_listeners() {
- $dispatcher = new WP_AI_Client_Event_Dispatcher();
- $event = new stdClass();
+ $dispatcher = new WP_AI_Client_Event_Dispatcher();
+ $event = new stdClass();
$event->test_value = 'original';
$result = $dispatcher->dispatch( $event );
From a5bd7925251365226d5621306a5ec19ee659c3c4 Mon Sep 17 00:00:00 2001
From: Jason Adams
Date: Wed, 11 Feb 2026 09:34:22 -0700
Subject: [PATCH 030/147] refactor: adds and runs third-party tree-shaking
---
.../class-wp-ai-client-cache.php | 209 ++++++++++++
.../third-party/Http/Client/Exception.php | 13 -
.../Http/Client/Exception/HttpException.php | 46 ---
.../Client/Exception/NetworkException.php | 25 --
.../Client/Exception/RequestAwareTrait.php | 20 --
.../Client/Exception/RequestException.php | 26 --
.../Client/Exception/TransferException.php | 13 -
.../Http/Client/HttpAsyncClient.php | 24 --
.../third-party/Http/Client/HttpClient.php | 16 -
.../Client/Promise/HttpFulfilledPromise.php | 39 ---
.../Client/Promise/HttpRejectedPromise.php | 42 ---
.../Http/Discovery/Composer/Plugin.php | 319 ------------------
.../Discovery/HttpAsyncClientDiscovery.php | 30 --
.../Http/Discovery/HttpClientDiscovery.php | 32 --
.../Discovery/MessageFactoryDiscovery.php | 32 --
.../Http/Discovery/NotFoundException.php | 15 -
.../Http/Discovery/Psr17Factory.php | 241 -------------
.../Http/Discovery/Psr18Client.php | 40 ---
.../Discovery/Strategy/MockClientStrategy.php | 22 --
.../Http/Discovery/StreamFactoryDiscovery.php | 32 --
.../Http/Discovery/UriFactoryDiscovery.php | 32 --
.../Http/Promise/FulfilledPromise.php | 45 ---
.../third-party/Http/Promise/Promise.php | 64 ----
.../Http/Promise/RejectedPromise.php | 42 ---
.../ListenerProviderInterface.php | 19 --
.../StoppableEventInterface.php | 26 --
.../Message/ServerRequestFactoryInterface.php | 24 --
.../Http/Message/ServerRequestInterface.php | 249 --------------
.../Message/UploadedFileFactoryInterface.php | 28 --
.../Http/Message/UploadedFileInterface.php | 118 -------
.../Psr/SimpleCache/CacheException.php | 10 -
.../SimpleCache/InvalidArgumentException.php | 13 -
src/wp-settings.php | 2 +
.../tests/ai-client/wpAiClientCache.php | 175 ++++++++++
tools/php-ai-client/installer.sh | 45 +++
35 files changed, 431 insertions(+), 1697 deletions(-)
create mode 100644 src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/ListenerProviderInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/StoppableEventInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestFactoryInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileInterface.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php
delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php
create mode 100644 tests/phpunit/tests/ai-client/wpAiClientCache.php
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