From ce79165b08ff1ffaece74a4e2b470fdde3cb43ed Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 5 Feb 2026 16:03:04 -0700 Subject: [PATCH 1/8] feat: adds tools for importing PHP AI Client --- tools/php-ai-client/installer.sh | 336 +++++++++++++++++++++++++++++ tools/php-ai-client/reorganize.php | 176 +++++++++++++++ tools/php-ai-client/scoper.inc.php | 123 +++++++++++ 3 files changed, 635 insertions(+) create mode 100755 tools/php-ai-client/installer.sh create mode 100644 tools/php-ai-client/reorganize.php create mode 100644 tools/php-ai-client/scoper.inc.php diff --git a/tools/php-ai-client/installer.sh b/tools/php-ai-client/installer.sh new file mode 100755 index 0000000000000..0134ae22bcad0 --- /dev/null +++ b/tools/php-ai-client/installer.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +# +# Installer script for bundling wordpress/php-ai-client into WordPress Core. +# +# Fetches the package, scopes Http\* dependencies via PHP-Scoper, generates +# a manual autoloader, and places everything into src/wp-includes/php-ai-client/. +# +# Usage: +# bash tools/php-ai-client/installer.sh --branch=refactor/removes-providers +# bash tools/php-ai-client/installer.sh --version=1.0.0 +# + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SCOPER_VERSION="0.18.17" +SCOPER_URL="https://github.com/humbug/php-scoper/releases/download/${SCOPER_VERSION}/php-scoper.phar" +GITHUB_REPO="https://github.com/WordPress/php-ai-client.git" + +TARGET_DIR="src/wp-includes/php-ai-client" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- + +VERSION="" +BRANCH="" + +for arg in "$@"; do + case "$arg" in + --version=*) + VERSION="${arg#--version=}" + ;; + --branch=*) + BRANCH="${arg#--branch=}" + ;; + --help|-h) + echo "Usage: $0 [--version=X.Y.Z | --branch=BRANCH]" + echo "" + echo "Options:" + echo " --version=X.Y.Z Fetch a specific release version" + echo " --branch=BRANCH Fetch from a branch (e.g. refactor/removes-providers)" + echo "" + echo "Must be run from the WordPress development repository root." + exit 0 + ;; + *) + echo "Error: Unknown argument: $arg" + echo "Run '$0 --help' for usage." + exit 1 + ;; + esac +done + +if [ -n "$VERSION" ] && [ -n "$BRANCH" ]; then + echo "Error: Cannot specify both --version and --branch." + exit 1 +fi + +if [ -z "$VERSION" ] && [ -z "$BRANCH" ]; then + echo "Error: Must specify either --version=X.Y.Z or --branch=BRANCH." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +check_command() { + if ! command -v "$1" &> /dev/null; then + echo "Error: '$1' is required but not found in PATH." + exit 1 + fi +} + +check_command php +check_command composer +check_command git + +# Verify we're running from the repo root. +if [ ! -f "wp-cli.yml" ] && [ ! -f "wp-config-sample.php" ] && [ ! -d "src/wp-includes" ]; then + echo "Error: This script must be run from the WordPress development repository root." + exit 1 +fi + +echo "==> Starting php-ai-client installer..." + +# --------------------------------------------------------------------------- +# Temp directory (cleaned on exit) +# --------------------------------------------------------------------------- + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +echo "==> Using temp directory: $TEMP_DIR" + +# --------------------------------------------------------------------------- +# Fetch package +# --------------------------------------------------------------------------- + +if [ -n "$BRANCH" ]; then + echo "==> Cloning branch '$BRANCH' from $GITHUB_REPO..." + git clone --depth 1 --branch "$BRANCH" "$GITHUB_REPO" "$TEMP_DIR/package" + echo "==> Installing Composer dependencies..." + composer install --no-dev --no-interaction --working-dir="$TEMP_DIR/package" + VENDOR_DIR="$TEMP_DIR/package/vendor" + CLIENT_SRC="$TEMP_DIR/package/src" +else + echo "==> Fetching version '$VERSION' via Composer..." + mkdir -p "$TEMP_DIR/package" + composer init --no-interaction --name="temp/installer" --working-dir="$TEMP_DIR/package" + composer require "wordpress/php-ai-client:${VERSION}" --no-dev --no-interaction --working-dir="$TEMP_DIR/package" + VENDOR_DIR="$TEMP_DIR/package/vendor" + CLIENT_SRC="$VENDOR_DIR/wordpress/php-ai-client/src" +fi + +if [ ! -d "$VENDOR_DIR" ]; then + echo "Error: vendor directory not found at $VENDOR_DIR" + exit 1 +fi + +echo "==> Package fetched successfully." + +# --------------------------------------------------------------------------- +# Clean target directory +# --------------------------------------------------------------------------- + +if [ -d "$TARGET_DIR" ]; then + echo "==> Removing existing $TARGET_DIR..." + rm -rf "$TARGET_DIR" +fi + +# --------------------------------------------------------------------------- +# Scope dependencies with PHP-Scoper +# --------------------------------------------------------------------------- + +SCOPER_PHAR="$TEMP_DIR/php-scoper.phar" + +echo "==> Downloading PHP-Scoper ${SCOPER_VERSION}..." +curl -fsSL "$SCOPER_URL" -o "$SCOPER_PHAR" +chmod +x "$SCOPER_PHAR" + +# Copy scoper config into temp dir. +cp "$SCRIPT_DIR/scoper.inc.php" "$TEMP_DIR/scoper.inc.php" + +SCOPED_DIR="$TEMP_DIR/scoped" + +echo "==> Running PHP-Scoper..." +php "$SCOPER_PHAR" add-prefix \ + --working-dir="$TEMP_DIR/package" \ + --config="$TEMP_DIR/scoper.inc.php" \ + --output-dir="$SCOPED_DIR" \ + --force \ + --no-interaction + +echo "==> Scoping complete." + +# --------------------------------------------------------------------------- +# Reorganize scoped output into namespace-based layout +# --------------------------------------------------------------------------- + +THIRD_PARTY_DIR="$TEMP_DIR/third-party" + +echo "==> Reorganizing dependencies..." +php "$SCRIPT_DIR/reorganize.php" \ + "$VENDOR_DIR/composer/installed.json" \ + "$SCOPED_DIR/vendor" \ + "$THIRD_PARTY_DIR" + +echo "==> Reorganization complete." + +# --------------------------------------------------------------------------- +# Copy files to target +# --------------------------------------------------------------------------- + +echo "==> Copying files to $TARGET_DIR..." + +mkdir -p "$TARGET_DIR/src" +mkdir -p "$TARGET_DIR/third-party" + +# Copy scoped AI client source. +# If installed via branch, scoped source is at scoped/src/. +# If installed via version, scoped source is at scoped/vendor/wordpress/php-ai-client/src/. +if [ -n "$BRANCH" ]; then + cp -R "$SCOPED_DIR/src/." "$TARGET_DIR/src/" +else + cp -R "$SCOPED_DIR/vendor/wordpress/php-ai-client/src/." "$TARGET_DIR/src/" +fi + +# Copy reorganized third-party dependencies. +cp -R "$THIRD_PARTY_DIR/." "$TARGET_DIR/third-party/" + +# --------------------------------------------------------------------------- +# Generate autoload.php +# --------------------------------------------------------------------------- + +echo "==> Generating autoload.php..." + +cat > "$TARGET_DIR/autoload.php" << 'AUTOLOAD_PHP' + 16, + 'Psr\\Http\\Message\\' => 17, + 'Psr\\EventDispatcher\\' => 21, + 'Psr\\SimpleCache\\' => 16, + ); + + $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; + } + + // 3. Psr\* interfaces → third-party/Psr/... + foreach ( $psr_prefixes as $prefix => $prefix_len ) { + if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { + $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. + $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; + if ( file_exists( $file ) ) { + require $file; + } + return; + } + } + } +); +AUTOLOAD_PHP + +echo "==> autoload.php generated." + +# --------------------------------------------------------------------------- +# Validate output +# --------------------------------------------------------------------------- + +echo "==> Validating output..." + +ERRORS=0 + +# Check key directories exist. +for dir in "$TARGET_DIR/src" "$TARGET_DIR/third-party"; do + if [ ! -d "$dir" ]; then + echo "Error: Expected directory not found: $dir" + ERRORS=$((ERRORS + 1)) + fi +done + +# Check autoloader exists and has valid syntax. +if [ ! -f "$TARGET_DIR/autoload.php" ]; then + echo "Error: autoload.php not found." + ERRORS=$((ERRORS + 1)) +else + if ! php -l "$TARGET_DIR/autoload.php" > /dev/null 2>&1; then + echo "Error: autoload.php has syntax errors." + php -l "$TARGET_DIR/autoload.php" + ERRORS=$((ERRORS + 1)) + fi +fi + +# Check that AiClient.php exists in source. +if [ ! -f "$TARGET_DIR/src/AiClient.php" ]; then + echo "Warning: src/AiClient.php not found. The package structure may differ." +fi + +# Check that Http dependencies are scoped. +if [ -d "$TARGET_DIR/third-party/Http" ]; then + SCOPED_COUNT=$(grep -rl "namespace WordPress\\\\AiClientDependencies\\\\Http" "$TARGET_DIR/third-party/Http/" 2>/dev/null | wc -l | tr -d ' ') + if [ "$SCOPED_COUNT" -eq 0 ]; then + echo "Warning: No scoped Http\\* namespaces found in third-party/Http/." + else + echo " Found $SCOPED_COUNT scoped Http\\* files." + fi +fi + +# Check that Psr interfaces are NOT scoped. +if [ -d "$TARGET_DIR/third-party/Psr" ]; then + UNSCOPED_PSR=$(grep -rL "namespace WordPress\\\\AiClientDependencies" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ') + echo " Found $UNSCOPED_PSR unscoped Psr\\* files." +fi + +if [ "$ERRORS" -gt 0 ]; then + echo "Error: Validation failed with $ERRORS error(s)." + exit 1 +fi + +echo "==> Validation passed." +echo "==> php-ai-client bundled successfully at $TARGET_DIR" +echo "" +echo "Next steps:" +echo " 1. Verify: ls -R $TARGET_DIR" +echo " 2. Test: php -r \"require '$TARGET_DIR/autoload.php'; var_dump(class_exists('WordPress\\\\AiClient\\\\AiClient'));\"" +echo " 3. Lint: composer lint:errors" diff --git a/tools/php-ai-client/reorganize.php b/tools/php-ai-client/reorganize.php new file mode 100644 index 0000000000000..026a95670a1c3 --- /dev/null +++ b/tools/php-ai-client/reorganize.php @@ -0,0 +1,176 @@ + + * + * @package WordPress + */ + +if ( $argc < 4 ) { + fwrite( STDERR, "Usage: php reorganize.php \n" ); + exit( 1 ); +} + +$installed_json_path = $argv[1]; +$scoped_vendor_dir = rtrim( $argv[2], '/' ); +$output_dir = rtrim( $argv[3], '/' ); + +if ( ! file_exists( $installed_json_path ) ) { + fwrite( STDERR, "Error: installed.json not found at: $installed_json_path\n" ); + exit( 1 ); +} + +if ( ! is_dir( $scoped_vendor_dir ) ) { + fwrite( STDERR, "Error: Scoped vendor directory not found at: $scoped_vendor_dir\n" ); + exit( 1 ); +} + +// --------------------------------------------------------------------------- +// Parse installed.json (handles Composer v1 and v2 formats). +// --------------------------------------------------------------------------- + +$installed_data = json_decode( file_get_contents( $installed_json_path ), true ); + +if ( null === $installed_data ) { + fwrite( STDERR, "Error: Failed to parse installed.json.\n" ); + exit( 1 ); +} + +// Composer v2 wraps packages in a "packages" key; v1 is a flat array. +if ( isset( $installed_data['packages'] ) && is_array( $installed_data['packages'] ) ) { + $packages = $installed_data['packages']; +} elseif ( isset( $installed_data[0] ) ) { + $packages = $installed_data; +} else { + fwrite( STDERR, "Error: Unrecognized installed.json format.\n" ); + exit( 1 ); +} + +// --------------------------------------------------------------------------- +// Process each dependency package. +// --------------------------------------------------------------------------- + +$files_autoload = array(); + +foreach ( $packages as $package ) { + $name = $package['name'] ?? ''; + + // Skip the AI client package itself. + if ( 'wordpress/php-ai-client' === $name ) { + continue; + } + + // Get PSR-4 autoload mappings. + $psr4 = $package['autoload']['psr-4'] ?? array(); + + if ( empty( $psr4 ) ) { + // Check for PSR-0 as fallback. + $psr0 = $package['autoload']['psr-0'] ?? array(); + if ( ! empty( $psr0 ) ) { + fwrite( STDERR, "Warning: Package '$name' uses PSR-0 autoloading (not fully supported). Skipping.\n" ); + } + // Still check for files autoload below. + } + + // Collect "files" autoload entries for future use. + $files = $package['autoload']['files'] ?? array(); + if ( ! empty( $files ) ) { + foreach ( $files as $file ) { + $files_autoload[] = array( + 'package' => $name, + 'file' => $file, + ); + } + } + + // Process PSR-4 mappings. + foreach ( $psr4 as $namespace_prefix => $source_dirs ) { + // Normalize source_dirs to array. + if ( ! is_array( $source_dirs ) ) { + $source_dirs = array( $source_dirs ); + } + + // Convert namespace prefix to directory path. + // e.g., "Http\\Client\\" → "Http/Client" + $namespace_path = rtrim( str_replace( '\\', '/', $namespace_prefix ), '/' ); + + // Determine the source directory in the scoped vendor output. + // Composer packages are at vendor/{package-name}/{source-dir}/. + foreach ( $source_dirs as $source_dir ) { + $source_dir = rtrim( $source_dir, '/' ); + + // Build the source path in the scoped vendor directory. + $source_path = $scoped_vendor_dir . '/' . $name; + if ( '' !== $source_dir ) { + $source_path .= '/' . $source_dir; + } + + if ( ! is_dir( $source_path ) ) { + fwrite( STDERR, "Warning: Source directory not found for '$name' at: $source_path\n" ); + continue; + } + + // Build the target path. + $target_path = $output_dir . '/' . $namespace_path; + + // Create target directory. + if ( ! is_dir( $target_path ) ) { + mkdir( $target_path, 0755, true ); + } + + // Copy files recursively. + copy_directory( $source_path, $target_path ); + + echo " Copied: $name ($namespace_prefix) → $namespace_path\n"; + } + } +} + +if ( ! empty( $files_autoload ) ) { + fwrite( STDERR, "\nNote: The following packages have 'files' autoload entries that may need manual handling:\n" ); + foreach ( $files_autoload as $entry ) { + fwrite( STDERR, " - {$entry['package']}: {$entry['file']}\n" ); + } +} + +echo "\nReorganization complete.\n"; + +// --------------------------------------------------------------------------- +// Helper functions. +// --------------------------------------------------------------------------- + +/** + * Recursively copy a directory. + * + * @param string $source Source directory path. + * @param string $dest Destination directory path. + */ +function copy_directory( string $source, string $dest ): void { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $source, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $item ) { + $target = $dest . '/' . $iterator->getSubPathname(); + + if ( $item->isDir() ) { + if ( ! is_dir( $target ) ) { + mkdir( $target, 0755, true ); + } + } else { + // Ensure parent directory exists. + $parent = dirname( $target ); + if ( ! is_dir( $parent ) ) { + mkdir( $parent, 0755, true ); + } + copy( $item->getPathname(), $target ); + } + } +} diff --git a/tools/php-ai-client/scoper.inc.php b/tools/php-ai-client/scoper.inc.php new file mode 100644 index 0000000000000..cbe0428a9909b --- /dev/null +++ b/tools/php-ai-client/scoper.inc.php @@ -0,0 +1,123 @@ + 'WordPress\\AiClientDependencies', + + 'finders' => array( + // Include all PHP files in vendor (dependencies) so their namespaces get scoped. + Finder::create() + ->files() + ->ignoreVCS( true ) + ->notName( '/LICENSE|.*\\.md|.*\\.dist|Makefile/' ) + ->exclude( array( 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) ) + ->in( 'vendor' ), + + // Include the AI client source files so `use` statements referencing + // scoped dependency namespaces get updated. The AI client's own namespace + // is excluded below, so its `namespace` declarations stay unchanged. + Finder::create() + ->files() + ->ignoreVCS( true ) + ->name( '*.php' ) + ->in( 'src' ), + ), + + 'exclude-namespaces' => array( + // The AI client's own namespace must not be scoped. + 'WordPress\\AiClient', + + // PSR interfaces stay global for type compatibility with external implementations. + 'Psr\\Http\\Client', + 'Psr\\Http\\Message', + 'Psr\\EventDispatcher', + 'Psr\\SimpleCache', + + // Composer's own namespace. + 'Composer', + ), + + 'exclude-files' => array(), + + 'exclude-constants' => array( + // Preserve WordPress-compatible constants. + '/^ABSPATH$/', + '/^WPINC$/', + ), + + 'exclude-functions' => array( + // polyfills.php defines global functions guarded by function_exists(). + 'str_starts_with', + 'str_ends_with', + 'str_contains', + 'array_is_list', + ), + + 'patchers' => array( + /** + * Fix php-http/discovery hardcoded class name strings. + * + * Discovery probes for external HTTP implementations using hardcoded FQCN strings. + * These must NOT be prefixed because they reference packages outside our bundle + * (e.g., GuzzleHttp\Client, Nyholm\Psr7\Factory\Psr17Factory). + */ + static function ( string $file_path, string $prefix, string $contents ): string { + // Only patch php-http/discovery files. + if ( false === strpos( $file_path, 'php-http/discovery' ) ) { + return $contents; + } + + // External package namespaces that Discovery probes for. + // These must remain un-prefixed in hardcoded string references. + $external_namespaces = array( + 'GuzzleHttp', + 'Http\\Adapter', + 'Http\\Client\\Curl', + 'Http\\Client\\Socket', + 'Http\\Client\\Buzz', + 'Http\\Client\\React', + 'Buzz', + 'Nyholm', + 'Laminas', + 'Symfony\\Component\\HttpClient', + 'Phalcon\\Http', + 'Slim\\Psr7', + 'Kriswallsmith', + ); + + foreach ( $external_namespaces as $ns ) { + $escaped_ns = preg_quote( $ns, '/' ); + $escaped_prefix = preg_quote( $prefix, '/' ); + + // Remove prefix from string literals containing these namespaces. + // Matches: 'WordPress\AiClientDependencies\GuzzleHttp\...' or "WordPress\AiClientDependencies\GuzzleHttp\..." + $contents = preg_replace( + '/([\'"])' . $escaped_prefix . '\\\\\\\\' . $escaped_ns . '/', + '$1' . $ns, + $contents + ); + + // Also handle double-backslash variants in string concatenation. + $contents = preg_replace( + '/([\'"])' . $escaped_prefix . '\\\\' . $escaped_ns . '/', + '$1' . $ns, + $contents + ); + } + + return $contents; + }, + ), +); From b69e828bb59c7d0341f9b22a07f79c6edef592a3 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 5 Feb 2026 16:03:52 -0700 Subject: [PATCH 2/8] feat: adds php-ai-client to includes --- phpcs.xml.dist | 1 + phpunit.xml.dist | 1 + src/wp-includes/php-ai-client/autoload.php | 68 + .../php-ai-client/src/AiClient.php | 367 +++++ .../src/Builders/MessageBuilder.php | 203 +++ .../src/Builders/PromptBuilder.php | 1343 +++++++++++++++++ .../src/Common/AbstractDataTransferObject.php | 126 ++ .../php-ai-client/src/Common/AbstractEnum.php | 349 +++++ .../Contracts/AiClientExceptionInterface.php | 17 + .../Common/Contracts/CachesDataInterface.php | 21 + .../WithArrayTransformationInterface.php | 42 + .../Contracts/WithJsonSchemaInterface.php | 24 + .../Exception/InvalidArgumentException.php | 17 + .../src/Common/Exception/RuntimeException.php | 17 + .../Common/Traits/WithDataCachingTrait.php | 162 ++ .../src/Events/AfterGenerateResultEvent.php | 115 ++ .../src/Events/BeforeGenerateResultEvent.php | 97 ++ .../php-ai-client/src/Files/DTO/File.php | 400 +++++ .../src/Files/Enums/FileTypeEnum.php | 31 + .../src/Files/Enums/MediaOrientationEnum.php | 39 + .../src/Files/ValueObjects/MimeType.php | 255 ++++ .../src/Messages/DTO/Message.php | 173 +++ .../src/Messages/DTO/MessagePart.php | 242 +++ .../src/Messages/DTO/ModelMessage.php | 32 + .../src/Messages/DTO/UserMessage.php | 31 + .../Messages/Enums/MessagePartChannelEnum.php | 27 + .../Messages/Enums/MessagePartTypeEnum.php | 39 + .../src/Messages/Enums/MessageRoleEnum.php | 27 + .../src/Messages/Enums/ModalityEnum.php | 45 + .../Contracts/OperationInterface.php | 33 + .../Operations/DTO/GenerativeAiOperation.php | 133 ++ .../Operations/Enums/OperationStateEnum.php | 45 + .../src/Providers/AbstractProvider.php | 120 ++ .../AbstractApiBasedModel.php | 111 ++ ...AbstractApiBasedModelMetadataDirectory.php | 105 ++ .../AbstractApiProvider.php | 49 + .../Contracts/ApiBasedModelInterface.php | 35 + ...nerateTextApiBasedProviderAvailability.php | 62 + ...ListModelsApiBasedProviderAvailability.php | 52 + .../ModelMetadataDirectoryInterface.php | 45 + .../ProviderAvailabilityInterface.php | 24 + .../Providers/Contracts/ProviderInterface.php | 55 + .../ProviderOperationsHandlerInterface.php | 29 + ...ProviderWithOperationsHandlerInterface.php | 24 + .../src/Providers/DTO/ProviderMetadata.php | 158 ++ .../Providers/DTO/ProviderModelsMetadata.php | 109 ++ .../src/Providers/Enums/ProviderTypeEnum.php | 33 + .../src/Providers/Enums/ToolTypeEnum.php | 27 + .../Http/Collections/HeadersCollection.php | 134 ++ .../Contracts/ClientWithOptionsInterface.php | 29 + .../Contracts/HttpTransporterInterface.php | 29 + .../RequestAuthenticationInterface.php | 24 + .../WithHttpTransporterInterface.php | 30 + .../WithRequestAuthenticationInterface.php | 30 + .../Http/DTO/ApiKeyRequestAuthentication.php | 92 ++ .../src/Providers/Http/DTO/Request.php | 358 +++++ .../src/Providers/Http/DTO/RequestOptions.php | 204 +++ .../src/Providers/Http/DTO/Response.php | 198 +++ .../Providers/Http/Enums/HttpMethodEnum.php | 110 ++ .../Enums/RequestAuthenticationMethod.php | 39 + .../Http/Exception/ClientException.php | 68 + .../Http/Exception/NetworkException.php | 57 + .../Http/Exception/RedirectException.php | 47 + .../Http/Exception/ResponseException.php | 46 + .../Http/Exception/ServerException.php | 46 + .../src/Providers/Http/HttpTransporter.php | 267 ++++ .../Providers/Http/HttpTransporterFactory.php | 33 + .../Http/Traits/WithHttpTransporterTrait.php | 40 + .../Traits/WithRequestAuthenticationTrait.php | 40 + .../Http/Util/ErrorMessageExtractor.php | 53 + .../src/Providers/Http/Util/ResponseUtil.php | 55 + .../Models/Contracts/ModelInterface.php | 52 + .../src/Providers/Models/DTO/ModelConfig.php | 855 +++++++++++ .../Providers/Models/DTO/ModelMetadata.php | 165 ++ .../Models/DTO/ModelRequirements.php | 315 ++++ .../Providers/Models/DTO/RequiredOption.php | 100 ++ .../Providers/Models/DTO/SupportedOption.php | 142 ++ .../Providers/Models/Enums/CapabilityEnum.php | 63 + .../src/Providers/Models/Enums/OptionEnum.php | 107 ++ .../ImageGenerationModelInterface.php | 26 + ...ImageGenerationOperationModelInterface.php | 26 + .../SpeechGenerationModelInterface.php | 26 + ...peechGenerationOperationModelInterface.php | 26 + .../TextGenerationModelInterface.php | 26 + .../TextGenerationOperationModelInterface.php | 26 + .../TextToSpeechConversionModelInterface.php | 26 + ...peechConversionOperationModelInterface.php | 26 + ...ctOpenAiCompatibleImageGenerationModel.php | 298 ++++ ...OpenAiCompatibleModelMetadataDirectory.php | 80 + ...actOpenAiCompatibleTextGenerationModel.php | 557 +++++++ .../src/Providers/ProviderRegistry.php | 520 +++++++ .../src/Results/Contracts/ResultInterface.php | 59 + .../src/Results/DTO/Candidate.php | 117 ++ .../src/Results/DTO/GenerativeAiResult.php | 420 ++++++ .../src/Results/DTO/TokenUsage.php | 118 ++ .../src/Results/Enums/FinishReasonEnum.php | 45 + .../src/Tools/DTO/FunctionCall.php | 128 ++ .../src/Tools/DTO/FunctionDeclaration.php | 122 ++ .../src/Tools/DTO/FunctionResponse.php | 119 ++ .../php-ai-client/src/Tools/DTO/WebSearch.php | 95 ++ .../php-ai-client/src/polyfills.php | 91 ++ .../third-party/Http/Client/Exception.php | 13 + .../Http/Client/Exception/HttpException.php | 46 + .../Client/Exception/NetworkException.php | 25 + .../Client/Exception/RequestAwareTrait.php | 20 + .../Client/Exception/RequestException.php | 26 + .../Client/Exception/TransferException.php | 13 + .../Http/Client/HttpAsyncClient.php | 24 + .../third-party/Http/Client/HttpClient.php | 16 + .../Client/Promise/HttpFulfilledPromise.php | 39 + .../Client/Promise/HttpRejectedPromise.php | 42 + .../Http/Discovery/ClassDiscovery.php | 219 +++ .../Http/Discovery/Composer/Plugin.php | 319 ++++ .../third-party/Http/Discovery/Exception.php | 12 + .../ClassInstantiationFailedException.php | 13 + .../Exception/DiscoveryFailedException.php | 45 + .../Exception/NoCandidateFoundException.php | 34 + .../Discovery/Exception/NotFoundException.php | 16 + .../Exception/PuliUnavailableException.php | 12 + .../StrategyUnavailableException.php | 14 + .../Discovery/HttpAsyncClientDiscovery.php | 30 + .../Http/Discovery/HttpClientDiscovery.php | 32 + .../Discovery/MessageFactoryDiscovery.php | 32 + .../Http/Discovery/NotFoundException.php | 15 + .../Http/Discovery/Psr17Factory.php | 241 +++ .../Http/Discovery/Psr17FactoryDiscovery.php | 119 ++ .../Http/Discovery/Psr18Client.php | 40 + .../Http/Discovery/Psr18ClientDiscovery.php | 31 + .../Strategy/CommonClassesStrategy.php | 116 ++ .../Strategy/CommonPsr17ClassesStrategy.php | 34 + .../Discovery/Strategy/DiscoveryStrategy.php | 22 + .../Discovery/Strategy/MockClientStrategy.php | 22 + .../Discovery/Strategy/PuliBetaStrategy.php | 77 + .../Http/Discovery/StreamFactoryDiscovery.php | 32 + .../Http/Discovery/UriFactoryDiscovery.php | 32 + .../Http/Promise/FulfilledPromise.php | 45 + .../third-party/Http/Promise/Promise.php | 64 + .../Http/Promise/RejectedPromise.php | 42 + .../EventDispatcherInterface.php | 21 + .../ListenerProviderInterface.php | 19 + .../StoppableEventInterface.php | 26 + .../Http/Client/ClientExceptionInterface.php | 10 + .../Psr/Http/Client/ClientInterface.php | 19 + .../Http/Client/NetworkExceptionInterface.php | 23 + .../Http/Client/RequestExceptionInterface.php | 23 + .../Psr/Http/Message/MessageInterface.php | 177 +++ .../Http/Message/RequestFactoryInterface.php | 18 + .../Psr/Http/Message/RequestInterface.php | 124 ++ .../Http/Message/ResponseFactoryInterface.php | 18 + .../Psr/Http/Message/ResponseInterface.php | 66 + .../Message/ServerRequestFactoryInterface.php | 24 + .../Http/Message/ServerRequestInterface.php | 249 +++ .../Http/Message/StreamFactoryInterface.php | 43 + .../Psr/Http/Message/StreamInterface.php | 144 ++ .../Message/UploadedFileFactoryInterface.php | 28 + .../Http/Message/UploadedFileInterface.php | 118 ++ .../Psr/Http/Message/UriFactoryInterface.php | 17 + .../Psr/Http/Message/UriInterface.php | 309 ++++ .../Psr/SimpleCache/CacheException.php | 10 + .../Psr/SimpleCache/CacheInterface.php | 107 ++ .../SimpleCache/InvalidArgumentException.php | 13 + src/wp-settings.php | 1 + 162 files changed, 15946 insertions(+) create mode 100644 src/wp-includes/php-ai-client/autoload.php create mode 100644 src/wp-includes/php-ai-client/src/AiClient.php create mode 100644 src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php create mode 100644 src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php create mode 100644 src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php create mode 100644 src/wp-includes/php-ai-client/src/Common/AbstractEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Contracts/CachesDataInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Contracts/WithArrayTransformationInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Exception/RuntimeException.php create mode 100644 src/wp-includes/php-ai-client/src/Common/Traits/WithDataCachingTrait.php create mode 100644 src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php create mode 100644 src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php create mode 100644 src/wp-includes/php-ai-client/src/Files/DTO/File.php create mode 100644 src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Files/Enums/MediaOrientationEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Files/ValueObjects/MimeType.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/DTO/Message.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartTypeEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/Enums/MessageRoleEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Messages/Enums/ModalityEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Operations/Contracts/OperationInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Operations/DTO/GenerativeAiOperation.php create mode 100644 src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/AbstractProvider.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/Contracts/ApiBasedModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/GenerateTextApiBasedProviderAvailability.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderOperationsHandlerInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderWithOperationsHandlerInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Enums/ToolTypeEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Collections/HeadersCollection.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Contracts/HttpTransporterInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Contracts/RequestAuthenticationInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Contracts/WithHttpTransporterInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Contracts/WithRequestAuthenticationInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/DTO/ApiKeyRequestAuthentication.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithHttpTransporterTrait.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Http/Util/ResponseUtil.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelConfig.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/Enums/OptionEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php create mode 100644 src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php create mode 100644 src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php create mode 100644 src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php create mode 100644 src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php create mode 100644 src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php create mode 100644 src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php create mode 100644 src/wp-includes/php-ai-client/src/Tools/DTO/FunctionCall.php create mode 100644 src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php create mode 100644 src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php create mode 100644 src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php create mode 100644 src/wp-includes/php-ai-client/src/polyfills.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php create mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/ListenerProviderInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/StoppableEventInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Client/ClientExceptionInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Client/ClientInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Client/NetworkExceptionInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Client/RequestExceptionInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/MessageInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ResponseFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ResponseInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UriFactoryInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UriInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheInterface.php create mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index a8387b3604c9b..3f2514003e14c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -73,6 +73,7 @@ /src/wp-includes/js/* /src/wp-includes/PHPMailer/* /src/wp-includes/Requests/* + /src/wp-includes/php-ai-client/* /src/wp-includes/SimplePie/* /src/wp-includes/sodium_compat/* /src/wp-includes/Text/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4b5b0d3ded110..4b6c149867c7d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -47,6 +47,7 @@ src/wp-includes/IXR src/wp-includes/PHPMailer src/wp-includes/Requests + src/wp-includes/php-ai-client src/wp-includes/SimplePie src/wp-includes/sodium_compat src/wp-includes/Text diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php new file mode 100644 index 0000000000000..89548a78aa737 --- /dev/null +++ b/src/wp-includes/php-ai-client/autoload.php @@ -0,0 +1,68 @@ + 16, + 'Psr\\Http\\Message\\' => 17, + 'Psr\\EventDispatcher\\' => 21, + 'Psr\\SimpleCache\\' => 16, + ); + + $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; + } + + // 3. Psr\* interfaces → third-party/Psr/... + foreach ( $psr_prefixes as $prefix => $prefix_len ) { + if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { + $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. + $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; + if ( file_exists( $file ) ) { + require $file; + } + return; + } + } + } +); diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php new file mode 100644 index 0000000000000..fb8e1ced1f4d2 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -0,0 +1,367 @@ +getProvider('openai')->getModel('gpt-4'); + * $result = AiClient::generateTextResult('What is PHP?', $model); + * ``` + * + * ### 2. ModelConfig for Auto-Discovery + * Use ModelConfig to specify requirements and let the system discover the best model: + * ```php + * $config = new ModelConfig(); + * $config->setTemperature(0.7); + * $config->setMaxTokens(150); + * + * $result = AiClient::generateTextResult('What is PHP?', $config); + * ``` + * + * ### 3. Automatic Discovery (Default) + * Pass null or omit the parameter for intelligent model discovery based on prompt content: + * ```php + * // System analyzes prompt and selects appropriate model automatically + * $result = AiClient::generateTextResult('What is PHP?'); + * $imageResult = AiClient::generateImageResult('A sunset over mountains'); + * ``` + * + * ## Fluent API Examples + * ```php + * // Fluent API with automatic model discovery + * $result = AiClient::prompt('Generate an image of a sunset') + * ->usingTemperature(0.7) + * ->generateImageResult(); + * + * // Fluent API with specific model + * $result = AiClient::prompt('What is PHP?') + * ->usingModel($specificModel) + * ->usingTemperature(0.5) + * ->generateTextResult(); + * + * // Fluent API with model configuration + * $result = AiClient::prompt('Explain quantum physics') + * ->usingModelConfig($config) + * ->generateTextResult(); + * ``` + * + * @since 0.1.0 + * + * @phpstan-import-type Prompt from PromptBuilder + * + * phpcs:ignore Generic.Files.LineLength.TooLong + */ +class AiClient +{ + /** + * @var string The version of the AI Client. + */ + public const VERSION = '0.4.1'; + /** + * @var ProviderRegistry|null The default provider registry instance. + */ + private static ?ProviderRegistry $defaultRegistry = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private static ?EventDispatcherInterface $eventDispatcher = null; + /** + * @var CacheInterface|null The PSR-16 cache for storing and retrieving cached data. + */ + private static ?CacheInterface $cache = null; + /** + * Gets the default provider registry instance. + * + * @since 0.1.0 + * + * @return ProviderRegistry The default provider registry. + */ + public static function defaultRegistry(): ProviderRegistry + { + if (self::$defaultRegistry === null) { + self::$defaultRegistry = new ProviderRegistry(); + } + return self::$defaultRegistry; + } + /** + * Sets the event dispatcher for prompt lifecycle events. + * + * The event dispatcher will be used to dispatch BeforeGenerateResultEvent and + * AfterGenerateResultEvent during prompt generation. + * + * @since 0.4.0 + * + * @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable. + * @return void + */ + public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void + { + self::$eventDispatcher = $dispatcher; + } + /** + * Gets the event dispatcher for prompt lifecycle events. + * + * @since 0.4.0 + * + * @return EventDispatcherInterface|null The event dispatcher, or null if not set. + */ + public static function getEventDispatcher(): ?EventDispatcherInterface + { + return self::$eventDispatcher; + } + /** + * Sets the PSR-16 cache for storing and retrieving cached data. + * + * The cache can be used to store AI responses and other data to avoid + * redundant API calls and improve performance. + * + * @since 0.4.0 + * + * @param CacheInterface|null $cache The PSR-16 cache instance, or null to disable caching. + * @return void + */ + public static function setCache(?CacheInterface $cache): void + { + self::$cache = $cache; + } + /** + * Gets the PSR-16 cache instance. + * + * @since 0.4.0 + * + * @return CacheInterface|null The cache instance, or null if not set. + */ + public static function getCache(): ?CacheInterface + { + return self::$cache; + } + /** + * Checks if a provider is configured and available for use. + * + * Supports multiple input formats for developer convenience: + * - ProviderAvailabilityInterface: Direct availability check + * - string (provider ID): e.g., AiClient::isConfigured('openai') + * - string (class name): e.g., AiClient::isConfigured(OpenAiProvider::class) + * + * When using string input, this method leverages the ProviderRegistry's centralized + * dependency management, ensuring HttpTransporter and authentication are properly + * injected into availability instances. + * + * @since 0.1.0 + * @since 0.2.0 Now supports being passed a provider ID or class name. + * + * @param ProviderAvailabilityInterface|string|class-string $availabilityOrIdOrClassName + * The provider availability instance, provider ID, or provider class name. + * @return bool True if the provider is configured and available, false otherwise. + */ + public static function isConfigured($availabilityOrIdOrClassName): bool + { + // Handle direct ProviderAvailabilityInterface (backward compatibility) + if ($availabilityOrIdOrClassName instanceof ProviderAvailabilityInterface) { + return $availabilityOrIdOrClassName->isConfigured(); + } + // Handle string input (provider ID or class name) via registry + if (is_string($availabilityOrIdOrClassName)) { + return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName); + } + throw new \InvalidArgumentException('Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' . sprintf('Received: %s', is_object($availabilityOrIdOrClassName) ? get_class($availabilityOrIdOrClassName) : gettype($availabilityOrIdOrClassName))); + } + /** + * Creates a new prompt builder for fluent API usage. + * + * Returns a PromptBuilder instance configured with the specified or default registry. + * The traditional API methods in this class delegate to PromptBuilder + * for all generation logic. + * + * @since 0.1.0 + * + * @param Prompt $prompt Optional initial prompt content. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return PromptBuilder The prompt builder instance. + */ + public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder + { + return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher); + } + /** + * Generates content using a unified API that automatically detects model capabilities. + * + * When no model is provided, this method delegates to PromptBuilder for intelligent + * model discovery based on prompt content and configuration. When a model is provided, + * it infers the capability from the model's interfaces and delegates to the capability-based method. + * + * @since 0.1.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig $modelOrConfig Specific model to use, or model configuration + * for auto-discovery. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the provided model doesn't support any known generation type. + * @throws \RuntimeException If no suitable model can be found for the prompt. + */ + public static function generateResult($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); + } + /** + * Generates text using the traditional API approach. + * + * @since 0.1.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); + } + /** + * Generates an image using the traditional API approach. + * + * @since 0.1.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); + } + /** + * Converts text to speech using the traditional API approach. + * + * @since 0.1.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); + } + /** + * Generates speech using the traditional API approach. + * + * @since 0.1.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); + } + /** + * Creates a new message builder for fluent API usage. + * + * This method will be implemented once MessageBuilder is available. + * MessageBuilder will provide a fluent interface for constructing complex + * messages with multiple parts, attachments, and metadata. + * + * @since 0.1.0 + * + * @param string|null $text Optional initial message text. + * @return object MessageBuilder instance (type will be updated when MessageBuilder is available). + * + * @throws \RuntimeException When MessageBuilder is not yet available. + */ + public static function message(?string $text = null) + { + throw new RuntimeException('MessageBuilder is not yet available. This method depends on builder infrastructure. ' . 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.'); + } + /** + * Validates that parameter is ModelInterface, ModelConfig, or null. + * + * @param mixed $modelOrConfig The parameter to validate. + * @return void + * @throws \InvalidArgumentException If parameter is invalid type. + */ + private static function validateModelOrConfigParameter($modelOrConfig): void + { + if ($modelOrConfig !== null && !$modelOrConfig instanceof ModelInterface && !$modelOrConfig instanceof ModelConfig) { + throw new InvalidArgumentException('Parameter must be a ModelInterface instance (specific model), ' . 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig))); + } + } + /** + * Configures PromptBuilder based on model/config parameter type. + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter. + * @param ProviderRegistry|null $registry Optional custom registry to use. + * @return PromptBuilder Configured prompt builder. + */ + private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder + { + $builder = self::prompt($prompt, $registry); + if ($modelOrConfig instanceof ModelInterface) { + $builder->usingModel($modelOrConfig); + } elseif ($modelOrConfig instanceof ModelConfig) { + $builder->usingModelConfig($modelOrConfig); + } + // null case: use default model discovery + return $builder; + } +} diff --git a/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php b/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php new file mode 100644 index 0000000000000..cc02f77e75d5b --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/MessageBuilder.php @@ -0,0 +1,203 @@ + The parts that make up the message. + */ + protected array $parts = []; + /** + * Constructor. + * + * @since 0.2.0 + * + * @param Input $input Optional initial content. + * @param MessageRoleEnum|null $role Optional role. + */ + public function __construct($input = null, ?MessageRoleEnum $role = null) + { + $this->role = $role; + if ($input === null) { + return; + } + // Handle different input types + if ($input instanceof MessagePart) { + $this->parts[] = $input; + } elseif (is_string($input)) { + $this->withText($input); + } elseif ($input instanceof File) { + $this->withFile($input); + } elseif ($input instanceof FunctionCall) { + $this->withFunctionCall($input); + } elseif ($input instanceof FunctionResponse) { + $this->withFunctionResponse($input); + } elseif (is_array($input) && MessagePart::isArrayShape($input)) { + $this->parts[] = MessagePart::fromArray($input); + } else { + throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.'); + } + } + /** + * Sets the role of the message sender. + * + * @since 0.2.0 + * + * @param MessageRoleEnum $role The role to set. + * @return self + */ + public function usingRole(MessageRoleEnum $role): self + { + $this->role = $role; + return $this; + } + /** + * Sets the role to user. + * + * @since 0.2.0 + * + * @return self + */ + public function usingUserRole(): self + { + return $this->usingRole(MessageRoleEnum::user()); + } + /** + * Sets the role to model. + * + * @since 0.2.0 + * + * @return self + */ + public function usingModelRole(): self + { + return $this->usingRole(MessageRoleEnum::model()); + } + /** + * Adds text content to the message. + * + * @since 0.2.0 + * + * @param string $text The text to add. + * @return self + * @throws InvalidArgumentException If the text is empty. + */ + public function withText(string $text): self + { + if (trim($text) === '') { + throw new InvalidArgumentException('Text content cannot be empty.'); + } + $this->parts[] = new MessagePart($text); + return $this; + } + /** + * Adds a file to the message. + * + * Accepts: + * - File object + * - URL string (remote file) + * - Base64-encoded data string + * - Data URI string (data:mime/type;base64,data) + * - Local file path string + * + * @since 0.2.0 + * + * @param string|File $file The file to add. + * @param string|null $mimeType Optional MIME type (ignored if File object provided). + * @return self + * @throws InvalidArgumentException If the file is invalid. + */ + public function withFile($file, ?string $mimeType = null): self + { + $file = $file instanceof File ? $file : new File($file, $mimeType); + $this->parts[] = new MessagePart($file); + return $this; + } + /** + * Adds a function call to the message. + * + * @since 0.2.0 + * + * @param FunctionCall $functionCall The function call to add. + * @return self + */ + public function withFunctionCall(FunctionCall $functionCall): self + { + $this->parts[] = new MessagePart($functionCall); + return $this; + } + /** + * Adds a function response to the message. + * + * @since 0.2.0 + * + * @param FunctionResponse $functionResponse The function response to add. + * @return self + */ + public function withFunctionResponse(FunctionResponse $functionResponse): self + { + $this->parts[] = new MessagePart($functionResponse); + return $this; + } + /** + * Adds multiple message parts to the message. + * + * @since 0.2.0 + * + * @param MessagePart ...$parts The message parts to add. + * @return self + */ + public function withMessageParts(MessagePart ...$parts): self + { + foreach ($parts as $part) { + $this->parts[] = $part; + } + return $this; + } + /** + * Builds and returns the Message object. + * + * @since 0.2.0 + * + * @return Message The built message. + * @throws InvalidArgumentException If the message validation fails. + */ + public function get(): Message + { + if (empty($this->parts)) { + throw new InvalidArgumentException('Cannot build an empty message. Add content using withText() or similar methods.'); + } + if ($this->role === null) { + throw new InvalidArgumentException('Cannot build a message with no role. Set a role using usingRole() or similar methods.'); + } + // At this point, we've validated that $this->role is not null + /** @var MessageRoleEnum $role */ + $role = $this->role; + return new Message($role, $this->parts); + } +} diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php new file mode 100644 index 0000000000000..d135df56c97fe --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -0,0 +1,1343 @@ +|list|null + */ +class PromptBuilder +{ + /** + * @var ProviderRegistry The provider registry for finding suitable models. + */ + private ProviderRegistry $registry; + /** + * @var list The messages in the conversation. + */ + protected array $messages = []; + /** + * @var ModelInterface|null The model to use for generation. + */ + protected ?ModelInterface $model = null; + /** + * @var list Ordered list of preference keys to check when selecting a model. + */ + protected array $modelPreferenceKeys = []; + /** + * @var string|null The provider ID or class name. + */ + protected ?string $providerIdOrClassName = null; + /** + * @var ModelConfig The model configuration. + */ + protected ModelConfig $modelConfig; + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + protected ?RequestOptions $requestOptions = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher = null; + // phpcs:disable Generic.Files.LineLength.TooLong + /** + * Constructor. + * + * @since 0.1.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param Prompt $prompt Optional initial prompt content. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. + */ + // phpcs:enable Generic.Files.LineLength.TooLong + public function __construct(ProviderRegistry $registry, $prompt = null, ?EventDispatcherInterface $eventDispatcher = null) + { + $this->registry = $registry; + $this->modelConfig = new ModelConfig(); + $this->eventDispatcher = $eventDispatcher; + if ($prompt === null) { + return; + } + // Check if it's a list of Messages - set as messages + if ($this->isMessagesList($prompt)) { + $this->messages = $prompt; + return; + } + // Parse it as a user message + $userMessage = $this->parseMessage($prompt, MessageRoleEnum::user()); + $this->messages[] = $userMessage; + } + /** + * Adds text to the current message. + * + * @since 0.1.0 + * + * @param string $text The text to add. + * @return self + */ + public function withText(string $text): self + { + $part = new MessagePart($text); + $this->appendPartToMessages($part); + return $this; + } + /** + * Adds a file to the current message. + * + * Accepts: + * - File object + * - URL string (remote file) + * - Base64-encoded data string + * - Data URI string (data:mime/type;base64,data) + * - Local file path string + * + * @since 0.1.0 + * + * @param string|File $file The file (File object or string representation). + * @param string|null $mimeType The MIME type (optional, ignored if File object provided). + * @return self + * @throws InvalidArgumentException If the file is invalid or MIME type cannot be determined. + */ + public function withFile($file, ?string $mimeType = null): self + { + $file = $file instanceof File ? $file : new File($file, $mimeType); + $part = new MessagePart($file); + $this->appendPartToMessages($part); + return $this; + } + /** + * Adds a function response to the current message. + * + * @since 0.1.0 + * + * @param FunctionResponse $functionResponse The function response. + * @return self + */ + public function withFunctionResponse(FunctionResponse $functionResponse): self + { + $part = new MessagePart($functionResponse); + $this->appendPartToMessages($part); + return $this; + } + /** + * Adds message parts to the current message. + * + * @since 0.1.0 + * + * @param MessagePart ...$parts The message parts to add. + * @return self + */ + public function withMessageParts(MessagePart ...$parts): self + { + foreach ($parts as $part) { + $this->appendPartToMessages($part); + } + return $this; + } + /** + * Adds conversation history messages. + * + * Historical messages are prepended to the beginning of the message list, + * before the current message being built. + * + * @since 0.1.0 + * + * @param Message ...$messages The messages to add to history. + * @return self + */ + public function withHistory(Message ...$messages): self + { + // Prepend the history messages to the beginning of the messages array + $this->messages = array_merge($messages, $this->messages); + return $this; + } + /** + * Sets the model to use for generation. + * + * The model's configuration will be merged with the builder's configuration, + * with the builder's configuration taking precedence for any overlapping settings. + * + * @since 0.1.0 + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + // Merge model's config with builder's config, with builder's config taking precedence + $modelConfigArray = $model->getConfig()->toArray(); + $builderConfigArray = $this->modelConfig->toArray(); + $mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray); + $this->modelConfig = ModelConfig::fromArray($mergedConfigArray); + return $this; + } + /** + * Sets preferred models to evaluate in order. + * + * @since 0.2.0 + * + * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, + * model instances, or [model ID, provider ID] tuples. + * @return self + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function usingModelPreference(...$preferredModels): self + { + if ($preferredModels === []) { + throw new InvalidArgumentException('At least one model preference must be provided.'); + } + $preferenceKeys = []; + foreach ($preferredModels as $preferredModel) { + if (is_array($preferredModel)) { + // [model identifier, provider ID] tuple + if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { + throw new InvalidArgumentException('Model preference tuple must contain model identifier and provider ID.'); + } + [$providerId, $modelId] = $preferredModel; + $modelId = $this->normalizePreferenceIdentifier($modelId); + $providerId = $this->normalizePreferenceIdentifier($providerId, 'Model preference provider identifiers cannot be empty.'); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif ($preferredModel instanceof ModelInterface) { + // Model instance + $modelId = $preferredModel->metadata()->getId(); + $providerId = $preferredModel->providerMetadata()->getId(); + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif (is_string($preferredModel)) { + // Model ID + $modelId = $this->normalizePreferenceIdentifier($preferredModel); + $preferenceKey = $this->createModelPreferenceKey($modelId); + } else { + // Invalid type + throw new InvalidArgumentException('Model preferences must be model identifiers, instances of ModelInterface, ' . 'or provider/model tuples.'); + } + $preferenceKeys[] = $preferenceKey; + } + $this->modelPreferenceKeys = $preferenceKeys; + return $this; + } + /** + * Sets the model configuration. + * + * Merges the provided configuration with the builder's configuration, + * with builder configuration taking precedence. + * + * @since 0.1.0 + * + * @param ModelConfig $config The model configuration to merge. + * @return self + */ + public function usingModelConfig(ModelConfig $config): self + { + // Convert both configs to arrays + $builderConfigArray = $this->modelConfig->toArray(); + $providedConfigArray = $config->toArray(); + // Merge arrays with builder config taking precedence + $mergedArray = array_merge($providedConfigArray, $builderConfigArray); + // Create new config from merged array + $this->modelConfig = ModelConfig::fromArray($mergedArray); + return $this; + } + /** + * Sets the provider to use for generation. + * + * @since 0.1.0 + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return self + */ + public function usingProvider(string $providerIdOrClassName): self + { + $this->providerIdOrClassName = $providerIdOrClassName; + return $this; + } + /** + * Sets the system instruction. + * + * System instructions are stored in the model configuration and guide + * the AI model's behavior throughout the conversation. + * + * @since 0.1.0 + * + * @param string $systemInstruction The system instruction text. + * @return self + */ + public function usingSystemInstruction(string $systemInstruction): self + { + $this->modelConfig->setSystemInstruction($systemInstruction); + return $this; + } + /** + * Sets the maximum number of tokens to generate. + * + * @since 0.1.0 + * + * @param int $maxTokens The maximum number of tokens. + * @return self + */ + public function usingMaxTokens(int $maxTokens): self + { + $this->modelConfig->setMaxTokens($maxTokens); + return $this; + } + /** + * Sets the temperature for generation. + * + * @since 0.1.0 + * + * @param float $temperature The temperature value. + * @return self + */ + public function usingTemperature(float $temperature): self + { + $this->modelConfig->setTemperature($temperature); + return $this; + } + /** + * Sets the top-p value for generation. + * + * @since 0.1.0 + * + * @param float $topP The top-p value. + * @return self + */ + public function usingTopP(float $topP): self + { + $this->modelConfig->setTopP($topP); + return $this; + } + /** + * Sets the top-k value for generation. + * + * @since 0.1.0 + * + * @param int $topK The top-k value. + * @return self + */ + public function usingTopK(int $topK): self + { + $this->modelConfig->setTopK($topK); + return $this; + } + /** + * Sets stop sequences for generation. + * + * @since 0.1.0 + * + * @param string ...$stopSequences The stop sequences. + * @return self + */ + public function usingStopSequences(string ...$stopSequences): self + { + $this->modelConfig->setCustomOption('stopSequences', $stopSequences); + return $this; + } + /** + * Sets the number of candidates to generate. + * + * @since 0.1.0 + * + * @param int $candidateCount The number of candidates. + * @return self + */ + public function usingCandidateCount(int $candidateCount): self + { + $this->modelConfig->setCandidateCount($candidateCount); + return $this; + } + /** + * Sets the function declarations available to the model. + * + * @since 0.1.0 + * + * @param FunctionDeclaration ...$functionDeclarations The function declarations. + * @return self + */ + public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclarations): self + { + $this->modelConfig->setFunctionDeclarations($functionDeclarations); + return $this; + } + /** + * Sets the presence penalty for generation. + * + * @since 0.1.0 + * + * @param float $presencePenalty The presence penalty value. + * @return self + */ + public function usingPresencePenalty(float $presencePenalty): self + { + $this->modelConfig->setPresencePenalty($presencePenalty); + return $this; + } + /** + * Sets the frequency penalty for generation. + * + * @since 0.1.0 + * + * @param float $frequencyPenalty The frequency penalty value. + * @return self + */ + public function usingFrequencyPenalty(float $frequencyPenalty): self + { + $this->modelConfig->setFrequencyPenalty($frequencyPenalty); + return $this; + } + /** + * Sets the web search configuration. + * + * @since 0.1.0 + * + * @param WebSearch $webSearch The web search configuration. + * @return self + */ + public function usingWebSearch(WebSearch $webSearch): self + { + $this->modelConfig->setWebSearch($webSearch); + return $this; + } + /** + * Sets the request options for HTTP transport. + * + * @since 0.3.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->requestOptions = $requestOptions; + return $this; + } + /** + * Sets the top log probabilities configuration. + * + * If $topLogprobs is null, enables log probabilities. + * If $topLogprobs has a value, enables log probabilities and sets the number of top log probabilities to return. + * + * @since 0.1.0 + * + * @param int|null $topLogprobs The number of top log probabilities to return, or null to enable log probabilities. + * @return self + */ + public function usingTopLogprobs(?int $topLogprobs = null): self + { + // Always enable log probabilities + $this->modelConfig->setLogprobs(\true); + // If a specific number is provided, set it + if ($topLogprobs !== null) { + $this->modelConfig->setTopLogprobs($topLogprobs); + } + return $this; + } + /** + * Sets the output MIME type. + * + * @since 0.1.0 + * + * @param string $mimeType The MIME type. + * @return self + */ + public function asOutputMimeType(string $mimeType): self + { + $this->modelConfig->setOutputMimeType($mimeType); + return $this; + } + /** + * Sets the output schema. + * + * @since 0.1.0 + * + * @param array $schema The output schema. + * @return self + */ + public function asOutputSchema(array $schema): self + { + $this->modelConfig->setOutputSchema($schema); + return $this; + } + /** + * Sets the output modalities. + * + * @since 0.1.0 + * + * @param ModalityEnum ...$modalities The output modalities. + * @return self + */ + public function asOutputModalities(ModalityEnum ...$modalities): self + { + $this->modelConfig->setOutputModalities($modalities); + return $this; + } + /** + * Sets the output file type. + * + * @since 0.1.0 + * + * @param FileTypeEnum $fileType The output file type. + * @return self + */ + public function asOutputFileType(FileTypeEnum $fileType): self + { + $this->modelConfig->setOutputFileType($fileType); + return $this; + } + /** + * Configures the prompt for JSON response output. + * + * @since 0.1.0 + * + * @param array|null $schema Optional JSON schema. + * @return self + */ + public function asJsonResponse(?array $schema = null): self + { + $this->asOutputMimeType('application/json'); + if ($schema !== null) { + $this->asOutputSchema($schema); + } + return $this; + } + /** + * Infers the capability from configured output modalities. + * + * @since 0.1.0 + * + * @return CapabilityEnum The inferred capability. + * @throws RuntimeException If the output modality is not supported. + */ + private function inferCapabilityFromOutputModalities(): CapabilityEnum + { + // Get the configured output modalities + $outputModalities = $this->modelConfig->getOutputModalities(); + // Default to text if no output modality is specified + if ($outputModalities === null || empty($outputModalities)) { + return CapabilityEnum::textGeneration(); + } + // Multi-modal output (multiple modalities) defaults to text generation. This is temporary + // as a multi-modal interface will be implemented in the future. + if (count($outputModalities) > 1) { + return CapabilityEnum::textGeneration(); + } + // Infer capability from single output modality + $outputModality = $outputModalities[0]; + if ($outputModality->isText()) { + return CapabilityEnum::textGeneration(); + } elseif ($outputModality->isImage()) { + return CapabilityEnum::imageGeneration(); + } elseif ($outputModality->isAudio()) { + return CapabilityEnum::speechGeneration(); + } elseif ($outputModality->isVideo()) { + return CapabilityEnum::videoGeneration(); + } else { + // For unsupported modalities, provide a clear error message + throw new RuntimeException(sprintf('Output modality "%s" is not yet supported.', $outputModality->value)); + } + } + /** + * Infers the capability from a model's implemented interfaces. + * + * @since 0.1.0 + * + * @param ModelInterface $model The model to infer capability from. + * @return CapabilityEnum|null The inferred capability, or null if none can be inferred. + */ + private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?CapabilityEnum + { + // Check model interfaces in order of preference + if ($model instanceof TextGenerationModelInterface) { + return CapabilityEnum::textGeneration(); + } + if ($model instanceof ImageGenerationModelInterface) { + return CapabilityEnum::imageGeneration(); + } + if ($model instanceof TextToSpeechConversionModelInterface) { + return CapabilityEnum::textToSpeechConversion(); + } + if ($model instanceof SpeechGenerationModelInterface) { + return CapabilityEnum::speechGeneration(); + } + // No supported interface found + return null; + } + /** + * Checks if the current prompt is supported by the selected model. + * + * @since 0.1.0 + * @since 0.3.0 Method visibility changed to public. + * + * @param CapabilityEnum|null $capability Optional capability to check support for. + * @return bool True if supported, false otherwise. + */ + public function isSupported(?CapabilityEnum $capability = null): bool + { + // If no intended capability provided, infer from output modalities + if ($capability === null) { + // First try to infer from a specific model if one is set + if ($this->model !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + if ($inferredCapability !== null) { + $capability = $inferredCapability; + } + } + // If still no capability, infer from output modalities + if ($capability === null) { + $capability = $this->inferCapabilityFromOutputModalities(); + } + } + // Build requirements with the specified capability + $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); + // If the model has been set, check if it meets the requirements + if ($this->model !== null) { + return $requirements->areMetBy($this->model->metadata()); + } + try { + // Check if any models support these requirements + $models = $this->registry->findModelsMetadataForSupport($requirements); + return !empty($models); + } catch (InvalidArgumentException $e) { + // No models support the requirements + return \false; + } + } + /** + * Checks if the prompt is supported for text generation. + * + * @since 0.1.0 + * + * @return bool True if text generation is supported. + */ + public function isSupportedForTextGeneration(): bool + { + return $this->isSupported(CapabilityEnum::textGeneration()); + } + /** + * Checks if the prompt is supported for image generation. + * + * @since 0.1.0 + * + * @return bool True if image generation is supported. + */ + public function isSupportedForImageGeneration(): bool + { + return $this->isSupported(CapabilityEnum::imageGeneration()); + } + /** + * Checks if the prompt is supported for text to speech conversion. + * + * @since 0.1.0 + * + * @return bool True if text to speech conversion is supported. + */ + public function isSupportedForTextToSpeechConversion(): bool + { + return $this->isSupported(CapabilityEnum::textToSpeechConversion()); + } + /** + * Checks if the prompt is supported for video generation. + * + * @since 0.1.0 + * + * @return bool True if video generation is supported. + */ + public function isSupportedForVideoGeneration(): bool + { + return $this->isSupported(CapabilityEnum::videoGeneration()); + } + /** + * Checks if the prompt is supported for speech generation. + * + * @since 0.1.0 + * + * @return bool True if speech generation is supported. + */ + public function isSupportedForSpeechGeneration(): bool + { + return $this->isSupported(CapabilityEnum::speechGeneration()); + } + /** + * Checks if the prompt is supported for music generation. + * + * @since 0.1.0 + * + * @return bool True if music generation is supported. + */ + public function isSupportedForMusicGeneration(): bool + { + return $this->isSupported(CapabilityEnum::musicGeneration()); + } + /** + * Checks if the prompt is supported for embedding generation. + * + * @since 0.1.0 + * + * @return bool True if embedding generation is supported. + */ + public function isSupportedForEmbeddingGeneration(): bool + { + return $this->isSupported(CapabilityEnum::embeddingGeneration()); + } + /** + * Generates a result from the prompt. + * + * This is the primary execution method that generates a result (containing + * potentially multiple candidates) based on the specified capability or + * the configured output modality. + * + * @since 0.1.0 + * + * @param CapabilityEnum|null $capability Optional capability to use for generation. + * If null, capability is inferred from output modality. + * @return GenerativeAiResult The generated result containing candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support the required capability. + */ + public function generateResult(?CapabilityEnum $capability = null): GenerativeAiResult + { + $this->validateMessages(); + // If capability is not provided, infer it + if ($capability === null) { + // First try to infer from a specific model if one is set + if ($this->model !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + if ($inferredCapability !== null) { + $capability = $inferredCapability; + } + } + // If still no capability, infer from output modalities + if ($capability === null) { + $capability = $this->inferCapabilityFromOutputModalities(); + } + } + $model = $this->getConfiguredModel($capability); + // Dispatch BeforeGenerateResultEvent + $this->dispatchEvent(new BeforeGenerateResultEvent($this->messages, $model, $capability)); + // Route to the appropriate generation method based on capability + $result = $this->executeModelGeneration($model, $capability, $this->messages); + // Dispatch AfterGenerateResultEvent + $this->dispatchEvent(new AfterGenerateResultEvent($this->messages, $model, $capability, $result)); + return $result; + } + /** + * Executes the model generation based on capability. + * + * @since 0.4.0 + * + * @param ModelInterface $model The model to use for generation. + * @param CapabilityEnum $capability The capability to use. + * @param list $messages The messages to send. + * @return GenerativeAiResult The generated result. + * @throws RuntimeException If the model doesn't support the required capability. + */ + private function executeModelGeneration(ModelInterface $model, CapabilityEnum $capability, array $messages): GenerativeAiResult + { + if ($capability->isTextGeneration()) { + if (!$model instanceof TextGenerationModelInterface) { + throw new RuntimeException(sprintf('Model "%s" does not support text generation.', $model->metadata()->getId())); + } + return $model->generateTextResult($messages); + } + if ($capability->isImageGeneration()) { + if (!$model instanceof ImageGenerationModelInterface) { + throw new RuntimeException(sprintf('Model "%s" does not support image generation.', $model->metadata()->getId())); + } + return $model->generateImageResult($messages); + } + if ($capability->isTextToSpeechConversion()) { + if (!$model instanceof TextToSpeechConversionModelInterface) { + throw new RuntimeException(sprintf('Model "%s" does not support text-to-speech conversion.', $model->metadata()->getId())); + } + return $model->convertTextToSpeechResult($messages); + } + if ($capability->isSpeechGeneration()) { + if (!$model instanceof SpeechGenerationModelInterface) { + throw new RuntimeException(sprintf('Model "%s" does not support speech generation.', $model->metadata()->getId())); + } + return $model->generateSpeechResult($messages); + } + // Video generation is not yet implemented + if ($capability->isVideoGeneration()) { + throw new RuntimeException('Output modality "video" is not yet supported.'); + } + // TODO: Add support for other capabilities when interfaces are available + throw new RuntimeException(sprintf('Capability "%s" is not yet supported for generation.', $capability->value)); + } + /** + * Generates a text result from the prompt. + * + * @since 0.1.0 + * + * @return GenerativeAiResult The generated result containing text candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support text generation. + */ + public function generateTextResult(): GenerativeAiResult + { + // Include text in output modalities + $this->includeOutputModalities(ModalityEnum::text()); + // Generate and return the result with text generation capability + return $this->generateResult(CapabilityEnum::textGeneration()); + } + /** + * Generates an image result from the prompt. + * + * @since 0.1.0 + * + * @return GenerativeAiResult The generated result containing image candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support image generation. + */ + public function generateImageResult(): GenerativeAiResult + { + // Include image in output modalities + $this->includeOutputModalities(ModalityEnum::image()); + // Generate and return the result with image generation capability + return $this->generateResult(CapabilityEnum::imageGeneration()); + } + /** + * Generates a speech result from the prompt. + * + * @since 0.1.0 + * + * @return GenerativeAiResult The generated result containing speech audio candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support speech generation. + */ + public function generateSpeechResult(): GenerativeAiResult + { + // Include audio in output modalities + $this->includeOutputModalities(ModalityEnum::audio()); + // Generate and return the result with speech generation capability + return $this->generateResult(CapabilityEnum::speechGeneration()); + } + /** + * Converts text to speech and returns the result. + * + * @since 0.1.0 + * + * @return GenerativeAiResult The generated result containing speech audio candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support text-to-speech conversion. + */ + public function convertTextToSpeechResult(): GenerativeAiResult + { + // Include audio in output modalities + $this->includeOutputModalities(ModalityEnum::audio()); + // Generate and return the result with text-to-speech conversion capability + return $this->generateResult(CapabilityEnum::textToSpeechConversion()); + } + /** + * Generates text from the prompt. + * + * @since 0.1.0 + * + * @return string The generated text. + * @throws InvalidArgumentException If the prompt or model validation fails. + */ + public function generateText(): string + { + return $this->generateTextResult()->toText(); + } + /** + * Generates multiple text candidates from the prompt. + * + * @since 0.1.0 + * + * @param int|null $candidateCount The number of candidates to generate. + * @return list The generated texts. + * @throws InvalidArgumentException If the prompt or model validation fails. + */ + public function generateTexts(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + // Generate text result + return $this->generateTextResult()->toTexts(); + } + /** + * Generates an image from the prompt. + * + * @since 0.1.0 + * + * @return File The generated image file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no image is generated. + */ + public function generateImage(): File + { + return $this->generateImageResult()->toFile(); + } + /** + * Generates multiple images from the prompt. + * + * @since 0.1.0 + * + * @param int|null $candidateCount The number of images to generate. + * @return list The generated image files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no images are generated. + */ + public function generateImages(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + return $this->generateImageResult()->toFiles(); + } + /** + * Converts text to speech. + * + * @since 0.1.0 + * + * @return File The generated speech audio file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function convertTextToSpeech(): File + { + return $this->convertTextToSpeechResult()->toFile(); + } + /** + * Converts text to multiple speech outputs. + * + * @since 0.1.0 + * + * @param int|null $candidateCount The number of speech outputs to generate. + * @return list The generated speech audio files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function convertTextToSpeeches(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + return $this->convertTextToSpeechResult()->toFiles(); + } + /** + * Generates speech from the prompt. + * + * @since 0.1.0 + * + * @return File The generated speech audio file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function generateSpeech(): File + { + return $this->generateSpeechResult()->toFile(); + } + /** + * Generates multiple speech outputs from the prompt. + * + * @since 0.1.0 + * + * @param int|null $candidateCount The number of speech outputs to generate. + * @return list The generated speech audio files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function generateSpeeches(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + return $this->generateSpeechResult()->toFiles(); + } + /** + * Appends a MessagePart to the messages array. + * + * If the last message has a user role, the part is added to it. + * Otherwise, a new UserMessage is created with the part. + * + * @since 0.1.0 + * + * @param MessagePart $part The part to append. + * @return void + */ + protected function appendPartToMessages(MessagePart $part): void + { + $lastMessage = end($this->messages); + if ($lastMessage instanceof Message && $lastMessage->getRole()->isUser()) { + // Replace the last message with a new one containing the appended part + array_pop($this->messages); + $this->messages[] = $lastMessage->withPart($part); + return; + } + // Create new UserMessage with the part + $this->messages[] = new UserMessage([$part]); + } + /** + * Gets the model to use for generation. + * + * If a model has been explicitly set, validates it meets requirements and returns it. + * Otherwise, finds a suitable model based on the prompt requirements. + * + * @since 0.1.0 + * + * @param CapabilityEnum $capability The capability the model will be using. + * @return ModelInterface The model to use. + * @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements. + */ + private function getConfiguredModel(CapabilityEnum $capability): ModelInterface + { + $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); + if ($this->model !== null) { + // Explicit model was provided via usingModel(); just update config and bind dependencies. + $model = $this->model; + $model->setConfig($this->modelConfig); + $this->registry->bindModelDependencies($model); + $this->bindModelRequestOptions($model); + return $model; + } + // Retrieve the candidate models map which satisfies the requirements. + $candidateMap = $this->getCandidateModelsMap($requirements); + if (empty($candidateMap)) { + $message = sprintf('No models found that support %s for this prompt.', $capability->value); + if ($this->providerIdOrClassName !== null) { + $message = sprintf('No models found for provider "%s" that support %s for this prompt.', $this->providerIdOrClassName, $capability->value); + } + throw new InvalidArgumentException($message); + } + // Check if any preferred models match the candidates, in priority order. + if (!empty($this->modelPreferenceKeys)) { + // Find preferences that match available candidates, preserving preference order. + $matchingPreferences = array_intersect_key(array_flip($this->modelPreferenceKeys), $candidateMap); + if (!empty($matchingPreferences)) { + // Get the first matching preference key + $firstMatchKey = key($matchingPreferences); + [$providerId, $modelId] = $candidateMap[$firstMatchKey]; + $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + } + // No preference matched; fall back to the first candidate discovered. + [$providerId, $modelId] = reset($candidateMap); + $model = $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); + $this->bindModelRequestOptions($model); + return $model; + } + /** + * Binds configured request options to the model if present and supported. + * + * Request options are only applicable to API-based models that make HTTP requests. + * + * @since 0.3.0 + * + * @param ModelInterface $model The model to bind request options to. + * @return void + */ + private function bindModelRequestOptions(ModelInterface $model): void + { + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + } + /** + * Builds a map of candidate models that satisfy the requirements for efficient lookup. + * + * @since 0.2.0 + * + * @param ModelRequirements $requirements The requirements derived from the prompt. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function getCandidateModelsMap(ModelRequirements $requirements): array + { + if ($this->providerIdOrClassName === null) { + // No provider locked in, gather all models across providers that meet requirements. + $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + $candidateMap = []; + foreach ($providerModelsMetadata as $providerModels) { + $providerId = $providerModels->getProvider()->getId(); + $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); + // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) + $candidateMap = $candidateMap + $providerMap; + } + return $candidateMap; + } + // Provider set, only consider models from that provider. + $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport($this->providerIdOrClassName, $requirements); + // Ensure we pass the provider ID, not the class name + $providerId = $this->registry->getProviderId($this->providerIdOrClassName); + return $this->generateMapFromCandidates($providerId, $modelsMetadata); + } + /** + * Generates a candidate map from model metadata with both provider-specific and model-only keys. + * + * @since 0.2.0 + * + * @param string $providerId The provider ID. + * @param list $modelsMetadata The models metadata to map. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array + { + $map = []; + foreach ($modelsMetadata as $modelMetadata) { + $modelId = $modelMetadata->getId(); + // Add provider-specific key + $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + $map[$providerModelKey] = [$providerId, $modelId]; + // Add model-only key + $modelKey = $this->createModelPreferenceKey($modelId); + $map[$modelKey] = [$providerId, $modelId]; + } + return $map; + } + /** + * Normalizes and validates a preference identifier string. + * + * @since 0.2.0 + * + * @param mixed $value The value to normalize. + * @param string $emptyMessage The message for empty or invalid values. + * @return string The normalized identifier. + * + * @throws InvalidArgumentException If the value is not a non-empty string. + */ + private function normalizePreferenceIdentifier($value, string $emptyMessage = 'Model preference identifiers cannot be empty.'): string + { + if (!is_string($value)) { + throw new InvalidArgumentException($emptyMessage); + } + $trimmed = trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException($emptyMessage); + } + return $trimmed; + } + /** + * Creates a preference key for a provider/model combination. + * + * @since 0.2.0 + * + * @param string $providerId The provider identifier. + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createProviderModelPreferenceKey(string $providerId, string $modelId): string + { + return 'providerModel::' . $providerId . '::' . $modelId; + } + /** + * Creates a preference key for a model identifier. + * + * @since 0.2.0 + * + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createModelPreferenceKey(string $modelId): string + { + return 'model::' . $modelId; + } + /** + * Parses various input types into a Message with the given role. + * + * @since 0.1.0 + * + * @param mixed $input The input to parse. + * @param MessageRoleEnum $defaultRole The role for the message if not specified by input. + * @return Message The parsed message. + * @throws InvalidArgumentException If the input type is not supported or results in empty message. + */ + private function parseMessage($input, MessageRoleEnum $defaultRole): Message + { + // Handle Message input directly + if ($input instanceof Message) { + return $input; + } + // Handle single MessagePart + if ($input instanceof MessagePart) { + return new Message($defaultRole, [$input]); + } + // Handle string input + if (is_string($input)) { + if (trim($input) === '') { + throw new InvalidArgumentException('Cannot create a message from an empty string.'); + } + return new Message($defaultRole, [new MessagePart($input)]); + } + // Handle array input + if (!is_array($input)) { + throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, ' . 'a list of string|MessagePart|MessagePartArrayShape, or a Message instance.'); + } + // Handle MessageArrayShape input + if (Message::isArrayShape($input)) { + return Message::fromArray($input); + } + // Check if it's a MessagePartArrayShape + if (MessagePart::isArrayShape($input)) { + return new Message($defaultRole, [MessagePart::fromArray($input)]); + } + // It should be a list of string|MessagePart|MessagePartArrayShape + if (!array_is_list($input)) { + throw new InvalidArgumentException('Array input must be a list array.'); + } + // Empty array check + if (empty($input)) { + throw new InvalidArgumentException('Cannot create a message from an empty array.'); + } + $parts = []; + foreach ($input as $item) { + if (is_string($item)) { + $parts[] = new MessagePart($item); + } elseif ($item instanceof MessagePart) { + $parts[] = $item; + } elseif (is_array($item) && MessagePart::isArrayShape($item)) { + $parts[] = MessagePart::fromArray($item); + } else { + throw new InvalidArgumentException('Array items must be strings, MessagePart instances, or MessagePartArrayShape.'); + } + } + return new Message($defaultRole, $parts); + } + /** + * Validates the messages array for prompt generation. + * + * Ensures that: + * - The first message is a user message + * - The last message is a user message + * - The last message has parts + * + * @since 0.1.0 + * + * @return void + * @throws InvalidArgumentException If validation fails. + */ + private function validateMessages(): void + { + if (empty($this->messages)) { + throw new InvalidArgumentException('Cannot generate from an empty prompt. Add content using withText() or similar methods.'); + } + $firstMessage = reset($this->messages); + if (!$firstMessage->getRole()->isUser()) { + throw new InvalidArgumentException('The first message must be from a user role, not from ' . $firstMessage->getRole()->value); + } + $lastMessage = end($this->messages); + if (!$lastMessage->getRole()->isUser()) { + throw new InvalidArgumentException('The last message must be from a user role, not from ' . $lastMessage->getRole()->value); + } + if (empty($lastMessage->getParts())) { + throw new InvalidArgumentException('The last message must have content parts. Add content using withText() or similar methods.'); + } + } + /** + * Checks if the value is a list of Message objects. + * + * @since 0.1.0 + * + * @param mixed $value The value to check. + * @return bool True if the value is a list of Message objects. + * + * @phpstan-assert-if-true list $value + */ + private function isMessagesList($value): bool + { + if (!is_array($value) || empty($value) || !array_is_list($value)) { + return \false; + } + // Check if all items are Messages + foreach ($value as $item) { + if (!$item instanceof Message) { + return \false; + } + } + return \true; + } + /** + * Includes output modalities if not already present. + * + * Adds the given modalities to the output modalities list if they're not + * already included. If output modalities is null, initializes it with + * the given modalities. + * + * @since 0.1.0 + * + * @param ModalityEnum ...$modalities The modalities to include. + * @return void + */ + private function includeOutputModalities(ModalityEnum ...$modalities): void + { + $existing = $this->modelConfig->getOutputModalities(); + // Initialize if null + if ($existing === null) { + $this->modelConfig->setOutputModalities($modalities); + return; + } + // Build a set of existing modality values for O(1) lookup + $existingValues = []; + foreach ($existing as $existingModality) { + $existingValues[$existingModality->value] = \true; + } + // Add new modalities that don't exist + $toAdd = []; + foreach ($modalities as $modality) { + if (!isset($existingValues[$modality->value])) { + $toAdd[] = $modality; + } + } + // Update if we have new modalities to add + if (!empty($toAdd)) { + $this->modelConfig->setOutputModalities(array_merge($existing, $toAdd)); + } + } + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since 0.4.0 + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php b/src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php new file mode 100644 index 0000000000000..cf396c9219415 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Common/AbstractDataTransferObject.php @@ -0,0 +1,126 @@ + + * @implements WithArrayTransformationInterface + */ +abstract class AbstractDataTransferObject implements WithArrayTransformationInterface, WithJsonSchemaInterface, JsonSerializable +{ + /** + * Validates that required keys exist in the array data. + * + * @since 0.1.0 + * + * @param array $data The array data to validate. + * @param string[] $requiredKeys The keys that must be present. + * @throws InvalidArgumentException If any required key is missing. + */ + protected static function validateFromArrayData(array $data, array $requiredKeys): void + { + $missingKeys = []; + foreach ($requiredKeys as $key) { + if (!array_key_exists($key, $data)) { + $missingKeys[] = $key; + } + } + if (!empty($missingKeys)) { + throw new InvalidArgumentException(sprintf('%s::fromArray() missing required keys: %s', static::class, implode(', ', $missingKeys))); + } + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function isArrayShape(array $array): bool + { + try { + /** @var TArrayShape $array */ + static::fromArray($array); + return \true; + } catch (InvalidArgumentException $e) { + return \false; + } + } + /** + * Converts the object to a JSON-serializable format. + * + * This method uses the toArray() method and then processes the result + * based on the JSON schema to ensure proper object representation for + * empty arrays. + * + * @since 0.1.0 + * + * @return mixed The JSON-serializable representation. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $data = $this->toArray(); + $schema = static::getJsonSchema(); + return $this->convertEmptyArraysToObjects($data, $schema); + } + /** + * Recursively converts empty arrays to stdClass objects where the schema expects objects. + * + * @since 0.1.0 + * + * @param mixed $data The data to process. + * @param array $schema The JSON schema for the data. + * @return mixed The processed data. + */ + private function convertEmptyArraysToObjects($data, array $schema) + { + // If data is an empty array and schema expects object, convert to stdClass + if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') { + return new stdClass(); + } + // If data is an array with content, recursively process nested structures + if (is_array($data)) { + // Handle object properties + if (isset($schema['properties']) && is_array($schema['properties'])) { + foreach ($data as $key => $value) { + if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) { + $data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]); + } + } + } + // Handle array items + if (isset($schema['items']) && is_array($schema['items'])) { + foreach ($data as $index => $item) { + $data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']); + } + } + // Handle oneOf schemas - just use the first one + if (isset($schema['oneOf']) && is_array($schema['oneOf'])) { + foreach ($schema['oneOf'] as $possibleSchema) { + if (is_array($possibleSchema)) { + return $this->convertEmptyArraysToObjects($data, $possibleSchema); + } + } + } + } + return $data; + } +} diff --git a/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php b/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php new file mode 100644 index 0000000000000..7589c70771901 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Common/AbstractEnum.php @@ -0,0 +1,349 @@ +name; // 'FIRST_NAME' + * $enum->value; // 'first' + * $enum->equals('first'); // Returns true + * $enum->is(PersonEnum::firstName()); // Returns true + * PersonEnum::cases(); // Returns array of all enum instances + * + * @property-read string $value The value of the enum instance. + * @property-read string $name The name of the enum constant. + * + * @since 0.1.0 + */ +abstract class AbstractEnum implements JsonSerializable +{ + /** + * @var string The value of the enum instance. + */ + private string $value; + /** + * @var string The name of the enum constant. + */ + private string $name; + /** + * @var array> Cache for reflection data. + */ + private static array $cache = []; + /** + * @var array> Cache for enum instances. + */ + private static array $instances = []; + /** + * Constructor is private to ensure instances are created through static methods. + * + * @since 0.1.0 + * + * @param string $value The enum value. + * @param string $name The constant name. + */ + final private function __construct(string $value, string $name) + { + $this->value = $value; + $this->name = $name; + } + /** + * Provides read-only access to properties. + * + * @since 0.1.0 + * + * @param string $property The property name. + * @return mixed The property value. + * @throws BadMethodCallException If property doesn't exist. + */ + final public function __get(string $property) + { + if ($property === 'value' || $property === 'name') { + return $this->{$property}; + } + throw new BadMethodCallException(sprintf('Property %s::%s does not exist', static::class, $property)); + } + /** + * Prevents property modification. + * + * @since 0.1.0 + * + * @param string $property The property name. + * @param mixed $value The value to set. + * @throws BadMethodCallException Always, as enum properties are read-only. + */ + final public function __set(string $property, $value): void + { + throw new BadMethodCallException(sprintf('Cannot modify property %s::%s - enum properties are read-only', static::class, $property)); + } + /** + * Creates an enum instance from a value, throws exception if invalid. + * + * @since 0.1.0 + * + * @param string $value The enum value. + * @return static The enum instance. + * @throws InvalidArgumentException If the value is not valid. + */ + final public static function from(string $value): self + { + $instance = self::tryFrom($value); + if ($instance === null) { + throw new InvalidArgumentException(sprintf('%s is not a valid backing value for enum %s', $value, static::class)); + } + return $instance; + } + /** + * Tries to create an enum instance from a value, returns null if invalid. + * + * @since 0.1.0 + * + * @param string $value The enum value. + * @return static|null The enum instance or null. + */ + final public static function tryFrom(string $value): ?self + { + $constants = static::getConstants(); + foreach ($constants as $name => $constantValue) { + if ($constantValue === $value) { + return self::getInstance($constantValue, $name); + } + } + return null; + } + /** + * Gets all enum cases. + * + * @since 0.1.0 + * + * @return static[] Array of all enum instances. + */ + final public static function cases(): array + { + $cases = []; + $constants = static::getConstants(); + foreach ($constants as $name => $value) { + $cases[] = self::getInstance($value, $name); + } + return $cases; + } + /** + * Checks if this enum has the same value as the given value. + * + * @since 0.1.0 + * + * @param string|self $other The value or enum to compare. + * @return bool True if values are equal. + */ + final public function equals($other): bool + { + if ($other instanceof self) { + return $this->is($other); + } + return $this->value === $other; + } + /** + * Checks if this enum is the same instance type and value as another enum. + * + * @since 0.1.0 + * + * @param self $other The other enum to compare. + * @return bool True if enums are identical. + */ + final public function is(self $other): bool + { + return $this === $other; + // Since we're using singletons, we can use identity comparison + } + /** + * Gets all valid values for this enum. + * + * @since 0.1.0 + * + * @return string[] List of all enum values. + */ + final public static function getValues(): array + { + return array_values(static::getConstants()); + } + /** + * Checks if a value is valid for this enum. + * + * @since 0.1.0 + * + * @param string $value The value to check. + * @return bool True if value is valid. + */ + final public static function isValidValue(string $value): bool + { + return in_array($value, self::getValues(), \true); + } + /** + * Gets or creates a singleton instance for the given value and name. + * + * @since 0.1.0 + * + * @param string $value The enum value. + * @param string $name The constant name. + * @return static The enum instance. + */ + private static function getInstance(string $value, string $name): self + { + $className = static::class; + if (!isset(self::$instances[$className])) { + self::$instances[$className] = []; + } + if (!isset(self::$instances[$className][$name])) { + $instance = new $className($value, $name); + self::$instances[$className][$name] = $instance; + } + /** @var static */ + return self::$instances[$className][$name]; + } + /** + * Gets all constants for this enum class. + * + * @since 0.1.0 + * + * @return array Map of constant names to values. + * @throws RuntimeException If invalid constant found. + */ + final protected static function getConstants(): array + { + $className = static::class; + if (!isset(self::$cache[$className])) { + self::$cache[$className] = static::determineClassEnumerations($className); + } + return self::$cache[$className]; + } + /** + * Determines the class enumerations by reflecting on class constants. + * + * This method can be overridden by subclasses to customize how + * enumerations are determined (e.g., to add dynamic constants). + * + * @since 0.1.0 + * + * @param class-string $className The fully qualified class name. + * @return array Map of constant names to values. + * @throws RuntimeException If invalid constant found. + */ + protected static function determineClassEnumerations(string $className): array + { + $reflection = new ReflectionClass($className); + $constants = $reflection->getConstants(); + // Validate all constants + $enumConstants = []; + foreach ($constants as $name => $value) { + // Check if constant name follows uppercase snake_case pattern + if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) { + throw new RuntimeException(sprintf('Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', $name, $className)); + } + // Check if value is valid type + if (!is_string($value)) { + throw new RuntimeException(sprintf('Invalid enum value type for constant %s::%s. ' . 'Only string values are allowed, %s given.', $className, $name, gettype($value))); + } + $enumConstants[$name] = $value; + } + return $enumConstants; + } + /** + * Handles dynamic method calls for enum checking. + * + * @since 0.1.0 + * + * @param string $name The method name. + * @param array $arguments The method arguments. + * @return bool True if the enum value matches. + * @throws BadMethodCallException If the method doesn't exist. + */ + final public function __call(string $name, array $arguments): bool + { + // Handle is* methods + if (str_starts_with($name, 'is')) { + $constantName = self::camelCaseToConstant(substr($name, 2)); + $constants = static::getConstants(); + if (isset($constants[$constantName])) { + return $this->value === $constants[$constantName]; + } + } + throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name)); + } + /** + * Handles static method calls for enum creation. + * + * @since 0.1.0 + * + * @param string $name The method name. + * @param array $arguments The method arguments. + * @return static The enum instance. + * @throws BadMethodCallException If the method doesn't exist. + */ + final public static function __callStatic(string $name, array $arguments): self + { + $constantName = self::camelCaseToConstant($name); + $constants = static::getConstants(); + if (isset($constants[$constantName])) { + return self::getInstance($constants[$constantName], $constantName); + } + throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name)); + } + /** + * Converts camelCase to CONSTANT_CASE. + * + * @since 0.1.0 + * + * @param string $camelCase The camelCase string. + * @return string The CONSTANT_CASE version. + */ + private static function camelCaseToConstant(string $camelCase): string + { + $snakeCase = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase); + if ($snakeCase === null) { + return strtoupper($camelCase); + } + return strtoupper($snakeCase); + } + /** + * Returns string representation of the enum. + * + * @since 0.1.0 + * + * @return string The enum value. + */ + final public function __toString(): string + { + return $this->value; + } + /** + * Converts the enum to a JSON-serializable format. + * + * @since 0.1.0 + * + * @return string The enum value. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->value; + } +} diff --git a/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php b/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php new file mode 100644 index 0000000000000..23d6256b20fa1 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Common/Contracts/AiClientExceptionInterface.php @@ -0,0 +1,17 @@ + + */ +interface WithArrayTransformationInterface +{ + /** + * Converts the object to an array representation. + * + * @since 0.1.0 + * + * @return TArrayShape The array representation. + */ + public function toArray(): array; + /** + * Creates an instance from array data. + * + * @since 0.1.0 + * + * @param TArrayShape $array The array data. + * @return self The created instance. + */ + public static function fromArray(array $array): self; + /** + * Checks if the array is a valid shape for this object. + * + * @since 0.1.0 + * + * @param array $array The array to check. + * @return bool True if the array is a valid shape. + * @phpstan-assert-if-true TArrayShape $array + */ + public static function isArrayShape(array $array): bool; +} diff --git a/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php b/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php new file mode 100644 index 0000000000000..a90375349476a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Common/Contracts/WithJsonSchemaInterface.php @@ -0,0 +1,24 @@ + The JSON schema as an associative array. + */ + public static function getJsonSchema(): array; +} diff --git a/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php b/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..7055cc926ae69 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Common/Exception/InvalidArgumentException.php @@ -0,0 +1,17 @@ + + */ + private array $localCache = []; + /** + * Gets the cache key suffixes managed by this object. + * + * @since 0.4.0 + * + * @return list The cache key suffixes. + */ + abstract protected function getCachedKeys(): array; + /** + * Gets the base cache key for this object. + * + * The base cache key is used as a prefix for all cache keys managed by this object. + * It should be unique to the implementing class to avoid cache key collisions. + * + * @since 0.4.0 + * + * @return string The base cache key. + */ + abstract protected function getBaseCacheKey(): string; + /** + * Checks if a value exists in the cache. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix (will be appended to the base key). + * @return bool True if the value exists in cache, false otherwise. + */ + protected function hasCache(string $key): bool + { + $fullKey = $this->buildCacheKey($key); + $cache = AiClient::getCache(); + if ($cache !== null) { + return $cache->has($fullKey); + } + return array_key_exists($fullKey, $this->localCache); + } + /** + * Gets a value from the cache, or computes and caches it if not present. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix (will be appended to the base key). + * @param callable $callback The callback to compute the value if not cached. + * @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default. + * Ignored for local cache. + * @return mixed The cached or computed value. + */ + protected function cached(string $key, callable $callback, $ttl = null) + { + if ($this->hasCache($key)) { + return $this->getCache($key); + } + $value = $callback(); + $this->setCache($key, $value, $ttl); + return $value; + } + /** + * Gets a value from the cache. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix (will be appended to the base key). + * @param mixed $default The default value to return if the key does not exist. + * @return mixed The cached value or the default value if not found. + */ + protected function getCache(string $key, $default = null) + { + $fullKey = $this->buildCacheKey($key); + $cache = AiClient::getCache(); + if ($cache !== null) { + return $cache->get($fullKey, $default); + } + return $this->localCache[$fullKey] ?? $default; + } + /** + * Sets a value in the cache. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix (will be appended to the base key). + * @param mixed $value The value to cache. + * @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default. Ignored for local cache. + * @return bool True on success, false on failure. + */ + protected function setCache(string $key, $value, $ttl = null): bool + { + $fullKey = $this->buildCacheKey($key); + $cache = AiClient::getCache(); + if ($cache !== null) { + return $cache->set($fullKey, $value, $ttl); + } + $this->localCache[$fullKey] = $value; + return \true; + } + /** + * Invalidates all caches managed by this object. + * + * @since 0.4.0 + * + * @return void + */ + public function invalidateCaches(): void + { + foreach ($this->getCachedKeys() as $key) { + $this->clearCache($key); + } + } + /** + * Clears a value from the cache. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix (will be appended to the base key). + * @return bool True on success, false on failure. + */ + protected function clearCache(string $key): bool + { + $fullKey = $this->buildCacheKey($key); + $cache = AiClient::getCache(); + if ($cache !== null) { + return $cache->delete($fullKey); + } + unset($this->localCache[$fullKey]); + return \true; + } + /** + * Builds the full cache key by combining the base key with the suffix. + * + * @since 0.4.0 + * + * @param string $key The cache key suffix. + * @return string The full cache key. + */ + private function buildCacheKey(string $key): string + { + return $this->getBaseCacheKey() . '_' . $key; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php b/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php new file mode 100644 index 0000000000000..d20c6fc07ba1b --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/AfterGenerateResultEvent.php @@ -0,0 +1,115 @@ + The messages that were sent to the model. + */ + private array $messages; + /** + * @var ModelInterface The model that processed the prompt. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum|null The capability that was used for generation. + */ + private ?CapabilityEnum $capability; + /** + * @var GenerativeAiResult The result from the model. + */ + private GenerativeAiResult $result; + /** + * Constructor. + * + * @since 0.4.0 + * + * @param list $messages The messages that were sent to the model. + * @param ModelInterface $model The model that processed the prompt. + * @param CapabilityEnum|null $capability The capability that was used for generation. + * @param GenerativeAiResult $result The result from the model. + */ + public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability, GenerativeAiResult $result) + { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + /** + * Gets the messages that were sent to the model. + * + * @since 0.4.0 + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + /** + * Gets the model that processed the prompt. + * + * @since 0.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability that was used for generation. + * + * @since 0.4.0 + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } + /** + * Gets the result from the model. + * + * @since 0.4.0 + * + * @return GenerativeAiResult The result. + */ + public function getResult(): GenerativeAiResult + { + return $this->result; + } + /** + * Performs a deep clone of the event. + * + * This method ensures that message and result objects are cloned to prevent + * modifications to the cloned event from affecting the original. + * The model object is not cloned as it is a service object. + * + * @since 0.4.1 + */ + public function __clone() + { + $clonedMessages = []; + foreach ($this->messages as $message) { + $clonedMessages[] = clone $message; + } + $this->messages = $clonedMessages; + $this->result = clone $this->result; + } +} diff --git a/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php new file mode 100644 index 0000000000000..553d9d8cad849 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Events/BeforeGenerateResultEvent.php @@ -0,0 +1,97 @@ + The messages to be sent to the model. + */ + private array $messages; + /** + * @var ModelInterface The model that will process the prompt. + */ + private ModelInterface $model; + /** + * @var CapabilityEnum|null The capability being used for generation. + */ + private ?CapabilityEnum $capability; + /** + * Constructor. + * + * @since 0.4.0 + * + * @param list $messages The messages to be sent to the model. + * @param ModelInterface $model The model that will process the prompt. + * @param CapabilityEnum|null $capability The capability being used for generation. + */ + public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability) + { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + } + /** + * Gets the messages to be sent to the model. + * + * @since 0.4.0 + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + /** + * Gets the model that will process the prompt. + * + * @since 0.4.0 + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + /** + * Gets the capability being used for generation. + * + * @since 0.4.0 + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } + /** + * Performs a deep clone of the event. + * + * This method ensures that message objects are cloned to prevent + * modifications to the cloned event from affecting the original. + * The model object is not cloned as it is a service object. + * + * @since 0.4.1 + */ + public function __clone() + { + $clonedMessages = []; + foreach ($this->messages as $message) { + $clonedMessages[] = clone $message; + } + $this->messages = $clonedMessages; + } +} diff --git a/src/wp-includes/php-ai-client/src/Files/DTO/File.php b/src/wp-includes/php-ai-client/src/Files/DTO/File.php new file mode 100644 index 0000000000000..c032041dae4ba --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Files/DTO/File.php @@ -0,0 +1,400 @@ + + */ +class File extends AbstractDataTransferObject +{ + public const KEY_FILE_TYPE = 'fileType'; + public const KEY_MIME_TYPE = 'mimeType'; + public const KEY_URL = 'url'; + public const KEY_BASE64_DATA = 'base64Data'; + /** + * @var MimeType The MIME type of the file. + */ + private MimeType $mimeType; + /** + * @var FileTypeEnum The type of file storage. + */ + private FileTypeEnum $fileType; + /** + * @var string|null The URL for remote files. + */ + private ?string $url = null; + /** + * @var string|null The base64 data for inline files. + */ + private ?string $base64Data = null; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $file The file string (URL, base64 data, or local path). + * @param string|null $mimeType The MIME type of the file (optional). + * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined. + */ + public function __construct(string $file, ?string $mimeType = null) + { + // Detect and process the file type (will set MIME type if possible) + $this->detectAndProcessFile($file, $mimeType); + } + /** + * Detects the file type and processes it accordingly. + * + * @since 0.1.0 + * + * @param string $file The file string to process. + * @param string|null $providedMimeType The explicitly provided MIME type. + * @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined. + */ + private function detectAndProcessFile(string $file, ?string $providedMimeType): void + { + // Check if it's a URL + if ($this->isUrl($file)) { + $this->fileType = FileTypeEnum::remote(); + $this->url = $file; + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + // Data URI pattern. + $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/'; + // Check if it's a data URI. + if (preg_match($dataUriPattern, $file, $matches)) { + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $matches[2]; + // Extract just the base64 data + $extractedMimeType = empty($matches[1]) ? null : $matches[1]; + $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null); + return; + } + // Check if it's a local file path (before base64 check) + if (file_exists($file) && is_file($file)) { + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $this->convertFileToBase64($file); + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + // Check if it's plain base64 + if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) { + if ($providedMimeType === null) { + throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.'); + } + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $file; + $this->mimeType = new MimeType($providedMimeType); + return; + } + throw new InvalidArgumentException('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + } + /** + * Checks if a string is a valid URL. + * + * @since 0.1.0 + * + * @param string $string The string to check. + * @return bool True if the string is a URL. + */ + private function isUrl(string $string): bool + { + return filter_var($string, \FILTER_VALIDATE_URL) !== \false && preg_match('/^https?:\/\//i', $string); + } + /** + * Converts a local file to base64. + * + * @since 0.1.0 + * + * @param string $filePath The path to the local file. + * @return string The base64-encoded file data. + * @throws RuntimeException If the file cannot be read. + */ + private function convertFileToBase64(string $filePath): string + { + $fileContent = @file_get_contents($filePath); + if ($fileContent === \false) { + throw new RuntimeException(sprintf('Unable to read file: %s', $filePath)); + } + return base64_encode($fileContent); + } + /** + * Gets the file type. + * + * @since 0.1.0 + * + * @return FileTypeEnum The file type. + */ + public function getFileType(): FileTypeEnum + { + return $this->fileType; + } + /** + * Checks if the file is an inline file. + * + * @since 0.1.0 + * + * @return bool True if the file is inline (base64/data URI). + */ + public function isInline(): bool + { + return $this->fileType->isInline(); + } + /** + * Checks if the file is a remote file. + * + * @since 0.1.0 + * + * @return bool True if the file is remote (URL). + */ + public function isRemote(): bool + { + return $this->fileType->isRemote(); + } + /** + * Gets the URL for remote files. + * + * @since 0.1.0 + * + * @return string|null The URL, or null if not a remote file. + */ + public function getUrl(): ?string + { + return $this->url; + } + /** + * Gets the base64-encoded data for inline files. + * + * @since 0.1.0 + * + * @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file. + */ + public function getBase64Data(): ?string + { + return $this->base64Data; + } + /** + * Gets the data as a data URI for inline files. + * + * @since 0.1.0 + * + * @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file. + */ + public function getDataUri(): ?string + { + if ($this->base64Data === null) { + return null; + } + return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data); + } + /** + * Gets the MIME type of the file as a string. + * + * @since 0.1.0 + * + * @return string The MIME type string value. + */ + public function getMimeType(): string + { + return (string) $this->mimeType; + } + /** + * Gets the MIME type object. + * + * @since 0.1.0 + * + * @return MimeType The MIME type object. + */ + public function getMimeTypeObject(): MimeType + { + return $this->mimeType; + } + /** + * Checks if the file is a video. + * + * @since 0.1.0 + * + * @return bool True if the file is a video. + */ + public function isVideo(): bool + { + return $this->mimeType->isVideo(); + } + /** + * Checks if the file is an image. + * + * @since 0.1.0 + * + * @return bool True if the file is an image. + */ + public function isImage(): bool + { + return $this->mimeType->isImage(); + } + /** + * Checks if the file is audio. + * + * @since 0.1.0 + * + * @return bool True if the file is audio. + */ + public function isAudio(): bool + { + return $this->mimeType->isAudio(); + } + /** + * Checks if the file is text. + * + * @since 0.1.0 + * + * @return bool True if the file is text. + */ + public function isText(): bool + { + return $this->mimeType->isText(); + } + /** + * Checks if the file is a document. + * + * @since 0.1.0 + * + * @return bool True if the file is a document. + */ + public function isDocument(): bool + { + return $this->mimeType->isDocument(); + } + /** + * Checks if the file is a specific MIME type. + * + * @since 0.1.0 + * + * @param string $type The mime type to check (e.g. 'image', 'text', 'video', 'audio'). + * + * @return bool True if the file is of the specified type. + */ + public function isMimeType(string $type): bool + { + return $this->mimeType->isType($type); + } + /** + * Determines the MIME type from various sources. + * + * @since 0.1.0 + * + * @param string|null $providedMimeType The explicitly provided MIME type. + * @param string|null $extractedMimeType The MIME type extracted from data URI. + * @param string|null $pathOrUrl The file path or URL to extract extension from. + * @return MimeType The determined MIME type. + * @throws InvalidArgumentException If MIME type cannot be determined. + */ + private function determineMimeType(?string $providedMimeType, ?string $extractedMimeType, ?string $pathOrUrl): MimeType + { + // Prefer explicitly provided MIME type + if ($providedMimeType !== null) { + return new MimeType($providedMimeType); + } + // Use extracted MIME type from data URI + if ($extractedMimeType !== null) { + return new MimeType($extractedMimeType); + } + // Try to determine from file extension + if ($pathOrUrl !== null) { + $parsedUrl = parse_url($pathOrUrl); + $path = $parsedUrl['path'] ?? $pathOrUrl; + // Remove query string and fragment if present + $cleanPath = strtok($path, '?#'); + if ($cleanPath === \false) { + $cleanPath = $path; + } + $extension = pathinfo($cleanPath, \PATHINFO_EXTENSION); + if (!empty($extension)) { + try { + return MimeType::fromExtension($extension); + } catch (InvalidArgumentException $e) { + // Extension not recognized, continue to error + unset($e); + } + } + } + throw new InvalidArgumentException('Unable to determine MIME type. Please provide it explicitly.'); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'oneOf' => [['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_URL => ['type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL]], ['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\-\^_+.]*$'], self::KEY_BASE64_DATA => ['type' => 'string', 'description' => 'The base64-encoded file data.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA]]]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return FileArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_FILE_TYPE => $this->fileType->value, self::KEY_MIME_TYPE => $this->getMimeType()]; + if ($this->url !== null) { + $data[self::KEY_URL] = $this->url; + } elseif (!$this->fileType->isRemote() && $this->base64Data !== null) { + $data[self::KEY_BASE64_DATA] = $this->base64Data; + } else { + throw new RuntimeException('File requires either url or base64Data. This should not be a possible condition.'); + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_FILE_TYPE]); + // Check which properties are set to determine how to construct the File + $mimeType = $array[self::KEY_MIME_TYPE] ?? null; + if (isset($array[self::KEY_URL])) { + return new self($array[self::KEY_URL], $mimeType); + } elseif (isset($array[self::KEY_BASE64_DATA])) { + return new self($array[self::KEY_BASE64_DATA], $mimeType); + } else { + throw new InvalidArgumentException('File requires either url or base64Data.'); + } + } + /** + * Performs a deep clone of the file. + * + * This method ensures that the MimeType value object is cloned to prevent + * any shared references between the original and cloned file. + * + * @since 0.4.1 + */ + public function __clone() + { + $this->mimeType = clone $this->mimeType; + } +} diff --git a/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php b/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php new file mode 100644 index 0000000000000..0f50ff93fa39f --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Files/Enums/FileTypeEnum.php @@ -0,0 +1,31 @@ + + */ + private static array $extensionMap = [ + // Text + 'txt' => 'text/plain', + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'md' => 'text/markdown', + // Images + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + // Documents + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + // Archives + 'zip' => 'application/zip', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + 'rar' => 'application/x-rar-compressed', + '7z' => 'application/x-7z-compressed', + // Audio + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'flac' => 'audio/flac', + 'm4a' => 'audio/m4a', + 'aac' => 'audio/aac', + // Video + 'mp4' => 'video/mp4', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'wmv' => 'video/x-ms-wmv', + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + // Fonts + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + // Other + 'php' => 'application/x-httpd-php', + 'sh' => 'application/x-sh', + 'exe' => 'application/x-msdownload', + ]; + /** + * Document MIME types. + * + * @var array + */ + private static array $documentTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet']; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $value The MIME type value. + * @throws InvalidArgumentException If the MIME type is invalid. + */ + public function __construct(string $value) + { + if (!self::isValid($value)) { + throw new InvalidArgumentException(sprintf('Invalid MIME type: %s', $value)); + } + $this->value = strtolower($value); + } + /** + * Gets the primary known file extension for this MIME type. + * + * @since 0.1.0 + * + * @return string The file extension (without the dot). + * @throws InvalidArgumentException If no known extension exists for this MIME type. + */ + public function toExtension(): string + { + // Reverse lookup for the MIME type to find the extension. + $extension = array_search($this->value, self::$extensionMap, \true); + if ($extension === \false) { + throw new InvalidArgumentException(sprintf('No known extension for MIME type: %s', $this->value)); + } + return $extension; + } + /** + * Creates a MimeType from a file extension. + * + * @since 0.1.0 + * + * @param string $extension The file extension (without the dot). + * @return self The MimeType instance. + * @throws InvalidArgumentException If the extension is not recognized. + */ + public static function fromExtension(string $extension): self + { + $extension = strtolower($extension); + if (!isset(self::$extensionMap[$extension])) { + throw new InvalidArgumentException(sprintf('Unknown file extension: %s', $extension)); + } + return new self(self::$extensionMap[$extension]); + } + /** + * Checks if a MIME type string is valid. + * + * @since 0.1.0 + * + * @param string $mimeType The MIME type to validate. + * @return bool True if valid. + */ + public static function isValid(string $mimeType): bool + { + // Basic MIME type validation: type/subtype + return (bool) preg_match('/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', $mimeType); + } + /** + * Checks if this MIME type is a specific type. + * + * This method returns true when the stored MIME type begins with the + * given prefix. For example, `"audio"` matches `"audio/mpeg"`. + * + * @since 0.1.0 + * + * @param string $mimeType The MIME type prefix to check (e.g., "audio", "image"). + * @return bool True if this MIME type is of the specified type. + */ + public function isType(string $mimeType): bool + { + return str_starts_with($this->value, strtolower($mimeType) . '/'); + } + /** + * Checks if this is an image MIME type. + * + * @since 0.1.0 + * + * @return bool True if this is an image type. + */ + public function isImage(): bool + { + return $this->isType('image'); + } + /** + * Checks if this is an audio MIME type. + * + * @since 0.1.0 + * + * @return bool True if this is an audio type. + */ + public function isAudio(): bool + { + return $this->isType('audio'); + } + /** + * Checks if this is a video MIME type. + * + * @since 0.1.0 + * + * @return bool True if this is a video type. + */ + public function isVideo(): bool + { + return $this->isType('video'); + } + /** + * Checks if this is a text MIME type. + * + * @since 0.1.0 + * + * @return bool True if this is a text type. + */ + public function isText(): bool + { + return $this->isType('text'); + } + /** + * Checks if this is a document MIME type. + * + * @since 0.1.0 + * + * @return bool True if this is a document type. + */ + public function isDocument(): bool + { + return in_array($this->value, self::$documentTypes, \true); + } + /** + * Checks if this MIME type equals another. + * + * @since 0.1.0 + * + * @param self|string $other The other MIME type to compare. + * @return bool True if equal. + * @throws InvalidArgumentException If the other MIME type is invalid. + */ + public function equals($other): bool + { + if ($other instanceof self) { + return $this->value === $other->value; + } + if (is_string($other)) { + return $this->value === strtolower($other); + } + throw new InvalidArgumentException(sprintf('Invalid MIME type comparison: %s', gettype($other))); + } + /** + * Gets the string representation of the MIME type. + * + * @since 0.1.0 + * + * @return string The MIME type value. + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php b/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php new file mode 100644 index 0000000000000..290685a58854e --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/DTO/Message.php @@ -0,0 +1,173 @@ + + * } + * + * @extends AbstractDataTransferObject + */ +class Message extends AbstractDataTransferObject +{ + public const KEY_ROLE = 'role'; + public const KEY_PARTS = 'parts'; + /** + * @var MessageRoleEnum The role of the message sender. + */ + protected MessageRoleEnum $role; + /** + * @var MessagePart[] The parts that make up this message. + */ + protected array $parts; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param MessageRoleEnum $role The role of the message sender. + * @param MessagePart[] $parts The parts that make up this message. + * @throws InvalidArgumentException If parts contain invalid content for the role. + */ + public function __construct(MessageRoleEnum $role, array $parts) + { + $this->role = $role; + $this->parts = $parts; + $this->validateParts(); + } + /** + * Gets the role of the message sender. + * + * @since 0.1.0 + * + * @return MessageRoleEnum The role. + */ + public function getRole(): MessageRoleEnum + { + return $this->role; + } + /** + * Gets the message parts. + * + * @since 0.1.0 + * + * @return MessagePart[] The message parts. + */ + public function getParts(): array + { + return $this->parts; + } + /** + * Returns a new instance with the given part appended. + * + * @since 0.1.0 + * + * @param MessagePart $part The part to append. + * @return Message A new instance with the part appended. + * @throws InvalidArgumentException If the part is invalid for the role. + */ + public function withPart(\WordPress\AiClient\Messages\DTO\MessagePart $part): \WordPress\AiClient\Messages\DTO\Message + { + $newParts = $this->parts; + $newParts[] = $part; + return new \WordPress\AiClient\Messages\DTO\Message($this->role, $newParts); + } + /** + * Validates that the message parts are appropriate for the message role. + * + * @since 0.1.0 + * + * @return void + * @throws InvalidArgumentException If validation fails. + */ + private function validateParts(): void + { + foreach ($this->parts as $part) { + $type = $part->getType(); + if ($this->role->isUser() && $type->isFunctionCall()) { + throw new InvalidArgumentException('User messages cannot contain function calls.'); + } + if ($this->role->isModel() && $type->isFunctionResponse()) { + throw new InvalidArgumentException('Model messages cannot contain function responses.'); + } + } + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ROLE => ['type' => 'string', 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.'], self::KEY_PARTS => ['type' => 'array', 'items' => \WordPress\AiClient\Messages\DTO\MessagePart::getJsonSchema(), 'minItems' => 1, 'description' => 'The parts that make up this message.']], 'required' => [self::KEY_ROLE, self::KEY_PARTS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return MessageArrayShape + */ + public function toArray(): array + { + return [self::KEY_ROLE => $this->role->value, self::KEY_PARTS => array_map(function (\WordPress\AiClient\Messages\DTO\MessagePart $part) { + return $part->toArray(); + }, $this->parts)]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return self The specific message class based on the role. + */ + final public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ROLE, self::KEY_PARTS]); + $role = MessageRoleEnum::from($array[self::KEY_ROLE]); + $partsData = $array[self::KEY_PARTS]; + $parts = array_map(function (array $partData) { + return \WordPress\AiClient\Messages\DTO\MessagePart::fromArray($partData); + }, $partsData); + // Determine which concrete class to instantiate based on role + if ($role->isUser()) { + return new \WordPress\AiClient\Messages\DTO\UserMessage($parts); + } elseif ($role->isModel()) { + return new \WordPress\AiClient\Messages\DTO\ModelMessage($parts); + } else { + // Only USER and MODEL roles are supported + throw new InvalidArgumentException('Invalid message role: ' . $role->value); + } + } + /** + * Performs a deep clone of the message. + * + * This method ensures that message part objects are cloned to prevent + * modifications to the cloned message from affecting the original. + * + * @since 0.4.1 + */ + public function __clone() + { + $clonedParts = []; + foreach ($this->parts as $part) { + $clonedParts[] = clone $part; + } + $this->parts = $clonedParts; + } +} diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php new file mode 100644 index 0000000000000..6728fd81cf697 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php @@ -0,0 +1,242 @@ + + */ +class MessagePart extends AbstractDataTransferObject +{ + public const KEY_CHANNEL = 'channel'; + public const KEY_TYPE = 'type'; + public const KEY_TEXT = 'text'; + public const KEY_FILE = 'file'; + public const KEY_FUNCTION_CALL = 'functionCall'; + public const KEY_FUNCTION_RESPONSE = 'functionResponse'; + /** + * @var MessagePartChannelEnum The channel this message part belongs to. + */ + private MessagePartChannelEnum $channel; + /** + * @var MessagePartTypeEnum The type of this message part. + */ + private MessagePartTypeEnum $type; + /** + * @var string|null Text content (when type is TEXT). + */ + private ?string $text = null; + /** + * @var File|null File data (when type is FILE). + */ + private ?File $file = null; + /** + * @var FunctionCall|null Function call request (when type is FUNCTION_CALL). + */ + private ?FunctionCall $functionCall = null; + /** + * @var FunctionResponse|null Function response (when type is FUNCTION_RESPONSE). + */ + private ?FunctionResponse $functionResponse = null; + /** + * Constructor that accepts various content types and infers the message part type. + * + * @since 0.1.0 + * + * @param mixed $content The content of this message part. + * @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT. + * @throws InvalidArgumentException If an unsupported content type is provided. + */ + public function __construct($content, ?MessagePartChannelEnum $channel = null) + { + $this->channel = $channel ?? MessagePartChannelEnum::content(); + if (is_string($content)) { + $this->type = MessagePartTypeEnum::text(); + $this->text = $content; + } elseif ($content instanceof File) { + $this->type = MessagePartTypeEnum::file(); + $this->file = $content; + } elseif ($content instanceof FunctionCall) { + $this->type = MessagePartTypeEnum::functionCall(); + $this->functionCall = $content; + } elseif ($content instanceof FunctionResponse) { + $this->type = MessagePartTypeEnum::functionResponse(); + $this->functionResponse = $content; + } else { + $type = is_object($content) ? get_class($content) : gettype($content); + throw new InvalidArgumentException(sprintf('Unsupported content type %s. Expected string, File, ' . 'FunctionCall, or FunctionResponse.', $type)); + } + } + /** + * Gets the channel this message part belongs to. + * + * @since 0.1.0 + * + * @return MessagePartChannelEnum The channel. + */ + public function getChannel(): MessagePartChannelEnum + { + return $this->channel; + } + /** + * Gets the type of this message part. + * + * @since 0.1.0 + * + * @return MessagePartTypeEnum The type. + */ + public function getType(): MessagePartTypeEnum + { + return $this->type; + } + /** + * Gets the text content. + * + * @since 0.1.0 + * + * @return string|null The text content or null if not a text part. + */ + public function getText(): ?string + { + return $this->text; + } + /** + * Gets the file. + * + * @since 0.1.0 + * + * @return File|null The file or null if not a file part. + */ + public function getFile(): ?File + { + return $this->file; + } + /** + * Gets the function call. + * + * @since 0.1.0 + * + * @return FunctionCall|null The function call or null if not a function call part. + */ + public function getFunctionCall(): ?FunctionCall + { + return $this->functionCall; + } + /** + * Gets the function response. + * + * @since 0.1.0 + * + * @return FunctionResponse|null The function response or null if not a function response part. + */ + public function getFunctionResponse(): ?FunctionResponse + { + return $this->functionResponse; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + $channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.']; + return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.']], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return MessagePartArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_CHANNEL => $this->channel->value, self::KEY_TYPE => $this->type->value]; + if ($this->text !== null) { + $data[self::KEY_TEXT] = $this->text; + } elseif ($this->file !== null) { + $data[self::KEY_FILE] = $this->file->toArray(); + } elseif ($this->functionCall !== null) { + $data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray(); + } elseif ($this->functionResponse !== null) { + $data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray(); + } else { + throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.'); + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + if (isset($array[self::KEY_CHANNEL])) { + $channel = MessagePartChannelEnum::from($array[self::KEY_CHANNEL]); + } else { + $channel = null; + } + // Check which properties are set to determine how to construct the MessagePart + if (isset($array[self::KEY_TEXT])) { + return new self($array[self::KEY_TEXT], $channel); + } elseif (isset($array[self::KEY_FILE])) { + return new self(File::fromArray($array[self::KEY_FILE]), $channel); + } elseif (isset($array[self::KEY_FUNCTION_CALL])) { + return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel); + } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) { + return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel); + } else { + throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.'); + } + } + /** + * Performs a deep clone of the message part. + * + * This method ensures that nested objects (file, function call, function response) + * are cloned to prevent modifications to the cloned part from affecting the original. + * + * @since 0.4.1 + */ + public function __clone() + { + if ($this->file !== null) { + $this->file = clone $this->file; + } + if ($this->functionCall !== null) { + $this->functionCall = clone $this->functionCall; + } + if ($this->functionResponse !== null) { + $this->functionResponse = clone $this->functionResponse; + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php b/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php new file mode 100644 index 0000000000000..e998e46cd8bff --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/DTO/ModelMessage.php @@ -0,0 +1,32 @@ +getRole()` + * to check the role of a message. + * + * @since 0.1.0 + */ +class ModelMessage extends \WordPress\AiClient\Messages\DTO\Message +{ + /** + * Constructor. + * + * @since 0.1.0 + * + * @param MessagePart[] $parts The parts that make up this message. + */ + public function __construct(array $parts) + { + parent::__construct(MessageRoleEnum::model(), $parts); + } +} diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php b/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php new file mode 100644 index 0000000000000..35e5349ff43f4 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/DTO/UserMessage.php @@ -0,0 +1,31 @@ +getRole()` + * to check the role of a message. + * + * @since 0.1.0 + */ +class UserMessage extends \WordPress\AiClient\Messages\DTO\Message +{ + /** + * Constructor. + * + * @since 0.1.0 + * + * @param MessagePart[] $parts The parts that make up this message. + */ + public function __construct(array $parts) + { + parent::__construct(MessageRoleEnum::user(), $parts); + } +} diff --git a/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php b/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php new file mode 100644 index 0000000000000..5b7cbf56559ba --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Messages/Enums/MessagePartChannelEnum.php @@ -0,0 +1,27 @@ + + */ +class GenerativeAiOperation extends AbstractDataTransferObject implements OperationInterface +{ + public const KEY_ID = 'id'; + public const KEY_STATE = 'state'; + public const KEY_RESULT = 'result'; + /** + * @var string Unique identifier for this operation. + */ + private string $id; + /** + * @var OperationStateEnum The current state of the operation. + */ + private OperationStateEnum $state; + /** + * @var GenerativeAiResult|null The result once the operation completes. + */ + private ?GenerativeAiResult $result; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $id Unique identifier for this operation. + * @param OperationStateEnum $state The current state of the operation. + * @param GenerativeAiResult|null $result The result once the operation completes. + */ + public function __construct(string $id, OperationStateEnum $state, ?GenerativeAiResult $result = null) + { + $this->id = $id; + $this->state = $state; + $this->result = $result; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getId(): string + { + return $this->id; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getState(): OperationStateEnum + { + return $this->state; + } + /** + * Gets the operation result. + * + * @since 0.1.0 + * + * @return GenerativeAiResult|null The result or null if not yet complete. + */ + public function getResult(): ?GenerativeAiResult + { + return $this->result; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['oneOf' => [ + // Succeeded state - has result + ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'const' => OperationStateEnum::succeeded()->value], self::KEY_RESULT => GenerativeAiResult::getJsonSchema()], 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], 'additionalProperties' => \false], + // All other states - no result + ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'enum' => [OperationStateEnum::starting()->value, OperationStateEnum::processing()->value, OperationStateEnum::failed()->value, OperationStateEnum::canceled()->value], 'description' => 'The current state of the operation.']], 'required' => [self::KEY_ID, self::KEY_STATE], 'additionalProperties' => \false], + ]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return GenerativeAiOperationArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_ID => $this->id, self::KEY_STATE => $this->state->value]; + if ($this->result !== null) { + $data[self::KEY_RESULT] = $this->result->toArray(); + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_STATE]); + $state = OperationStateEnum::from($array[self::KEY_STATE]); + if ($state->isSucceeded()) { + // If the operation has succeeded, it must have a result + static::validateFromArrayData($array, [self::KEY_RESULT]); + } + $result = null; + if (isset($array[self::KEY_RESULT])) { + $result = GenerativeAiResult::fromArray($array[self::KEY_RESULT]); + } + return new self($array[self::KEY_ID], $state, $result); + } +} diff --git a/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php b/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php new file mode 100644 index 0000000000000..034cea04b3fe8 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Operations/Enums/OperationStateEnum.php @@ -0,0 +1,45 @@ + Cache for provider metadata per class. + */ + private static array $metadataCache = []; + /** + * @var array Cache for provider availability per class. + */ + private static array $availabilityCache = []; + /** + * @var array Cache for model metadata directory per class. + */ + private static array $modelMetadataDirectoryCache = []; + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public static function metadata(): ProviderMetadata + { + $className = static::class; + if (!isset(self::$metadataCache[$className])) { + self::$metadataCache[$className] = static::createProviderMetadata(); + } + return self::$metadataCache[$className]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface + { + $providerMetadata = static::metadata(); + $modelMetadata = static::modelMetadataDirectory()->getModelMetadata($modelId); + $model = static::createModel($modelMetadata, $providerMetadata); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public static function availability(): ProviderAvailabilityInterface + { + $className = static::class; + if (!isset(self::$availabilityCache[$className])) { + self::$availabilityCache[$className] = static::createProviderAvailability(); + } + return self::$availabilityCache[$className]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface + { + $className = static::class; + if (!isset(self::$modelMetadataDirectoryCache[$className])) { + self::$modelMetadataDirectoryCache[$className] = static::createModelMetadataDirectory(); + } + return self::$modelMetadataDirectoryCache[$className]; + } + /** + * Creates a model instance based on the given model metadata and provider metadata. + * + * @since 0.1.0 + * + * @param ModelMetadata $modelMetadata The model metadata. + * @param ProviderMetadata $providerMetadata The provider metadata. + * @return ModelInterface The new model instance. + */ + abstract protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface; + /** + * Creates the provider metadata instance. + * + * @since 0.1.0 + * + * @return ProviderMetadata The provider metadata. + */ + abstract protected static function createProviderMetadata(): ProviderMetadata; + /** + * Creates the provider availability instance. + * + * @since 0.1.0 + * + * @return ProviderAvailabilityInterface The provider availability. + */ + abstract protected static function createProviderAvailability(): ProviderAvailabilityInterface; + /** + * Creates the model metadata directory instance. + * + * @since 0.1.0 + * + * @return ModelMetadataDirectoryInterface The model metadata directory. + */ + abstract protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php new file mode 100644 index 0000000000000..30705e64cb37c --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php @@ -0,0 +1,111 @@ +metadata = $metadata; + $this->providerMetadata = $providerMetadata; + $this->config = ModelConfig::fromArray([]); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function metadata(): ModelMetadata + { + return $this->metadata; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function getConfig(): ModelConfig + { + return $this->config; + } + /** + * {@inheritDoc} + * + * @since 0.3.0 + */ + final public function setRequestOptions(RequestOptions $requestOptions): void + { + $this->requestOptions = $requestOptions; + } + /** + * {@inheritDoc} + * + * @since 0.3.0 + */ + final public function getRequestOptions(): ?RequestOptions + { + return $this->requestOptions; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php new file mode 100644 index 0000000000000..4f7e2a338fabc --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -0,0 +1,105 @@ +getModelMetadataMap(); + return array_values($modelsMetadata); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function hasModelMetadata(string $modelId): bool + { + $modelsMetadata = $this->getModelMetadataMap(); + return isset($modelsMetadata[$modelId]); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function getModelMetadata(string $modelId): ModelMetadata + { + $modelsMetadata = $this->getModelMetadataMap(); + if (!isset($modelsMetadata[$modelId])) { + throw new InvalidArgumentException(sprintf('No model with ID %s was found in the provider', $modelId)); + } + return $modelsMetadata[$modelId]; + } + /** + * Returns the map of model ID to model metadata for all models from the provider. + * + * @since 0.1.0 + * + * @return array Map of model ID to model metadata. + */ + private function getModelMetadataMap(): array + { + /** @var array */ + return $this->cached(self::MODELS_CACHE_KEY, fn() => $this->sendListModelsRequest(), 86400); + } + /** + * {@inheritDoc} + * + * @since 0.4.0 + */ + protected function getCachedKeys(): array + { + return [self::MODELS_CACHE_KEY]; + } + /** + * {@inheritDoc} + * + * @since 0.4.0 + */ + protected function getBaseCacheKey(): string + { + return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class); + } + /** + * Sends the API request to list models from the provider and returns the map of model ID to model metadata. + * + * @since 0.1.0 + * + * @return array Map of model ID to model metadata. + */ + abstract protected function sendListModelsRequest(): array; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php new file mode 100644 index 0000000000000..70a84873a2323 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/AbstractApiProvider.php @@ -0,0 +1,49 @@ +model = $model; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function isConfigured(): bool + { + // Set config to use as few resources as possible for the test. + $modelConfig = ModelConfig::fromArray([ModelConfig::KEY_MAX_TOKENS => 1]); + $this->model->setConfig($modelConfig); + try { + // Attempt to generate text to check if the provider is available. + $this->model->generateTextResult([new Message(MessageRoleEnum::user(), [new MessagePart('a')])]); + return \true; + } catch (Exception $e) { + // If an exception occurs, the provider is not available. + return \false; + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php new file mode 100644 index 0000000000000..128184e737df8 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php @@ -0,0 +1,52 @@ +modelMetadataDirectory = $modelMetadataDirectory; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function isConfigured(): bool + { + try { + // Attempt to list models to check if the provider is available. + $this->modelMetadataDirectory->listModelMetadata(); + return \true; + } catch (Exception $e) { + // If an exception occurs, the provider is not available. + return \false; + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php b/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php new file mode 100644 index 0000000000000..52be8c357c0ff --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Contracts/ModelMetadataDirectoryInterface.php @@ -0,0 +1,45 @@ + Array of model metadata. + */ + public function listModelMetadata(): array; + /** + * Checks if metadata exists for a specific model. + * + * @since 0.1.0 + * + * @param string $modelId Model identifier. + * @return bool True if metadata exists, false otherwise. + */ + public function hasModelMetadata(string $modelId): bool; + /** + * Gets metadata for a specific model. + * + * @since 0.1.0 + * + * @param string $modelId Model identifier. + * @return ModelMetadata Model metadata. + * @throws InvalidArgumentException If model metadata not found. + */ + public function getModelMetadata(string $modelId): ModelMetadata; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php b/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php new file mode 100644 index 0000000000000..a5b2737fdb48b --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Contracts/ProviderAvailabilityInterface.php @@ -0,0 +1,24 @@ + + */ +class ProviderMetadata extends AbstractDataTransferObject +{ + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_TYPE = 'type'; + public const KEY_CREDENTIALS_URL = 'credentialsUrl'; + public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod'; + /** + * @var string The provider's unique identifier. + */ + protected string $id; + /** + * @var string The provider's display name. + */ + protected string $name; + /** + * @var ProviderTypeEnum The provider type. + */ + protected ProviderTypeEnum $type; + /** + * @var string|null The URL where users can get credentials. + */ + protected ?string $credentialsUrl; + /** + * @var RequestAuthenticationMethod|null The authentication method. + */ + protected ?RequestAuthenticationMethod $authenticationMethod; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $id The provider's unique identifier. + * @param string $name The provider's display name. + * @param ProviderTypeEnum $type The provider type. + * @param string|null $credentialsUrl The URL where users can get credentials. + * @param RequestAuthenticationMethod|null $authenticationMethod The authentication method. + */ + public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null) + { + $this->id = $id; + $this->name = $name; + $this->type = $type; + $this->credentialsUrl = $credentialsUrl; + $this->authenticationMethod = $authenticationMethod; + } + /** + * Gets the provider's unique identifier. + * + * @since 0.1.0 + * + * @return string The provider ID. + */ + public function getId(): string + { + return $this->id; + } + /** + * Gets the provider's display name. + * + * @since 0.1.0 + * + * @return string The provider name. + */ + public function getName(): string + { + return $this->name; + } + /** + * Gets the provider type. + * + * @since 0.1.0 + * + * @return ProviderTypeEnum The provider type. + */ + public function getType(): ProviderTypeEnum + { + return $this->type; + } + /** + * Gets the credentials URL. + * + * @since 0.1.0 + * + * @return string|null The credentials URL. + */ + public function getCredentialsUrl(): ?string + { + return $this->credentialsUrl; + } + /** + * Gets the authentication method. + * + * @since 0.4.0 + * + * @return RequestAuthenticationMethod|null The authentication method. + */ + public function getAuthenticationMethod(): ?RequestAuthenticationMethod + { + return $this->authenticationMethod; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ProviderMetadataArrayShape + */ + public function toArray(): array + { + return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]); + return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php new file mode 100644 index 0000000000000..29d66cab05ec5 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderModelsMetadata.php @@ -0,0 +1,109 @@ + + * } + * + * @extends AbstractDataTransferObject + */ +class ProviderModelsMetadata extends AbstractDataTransferObject +{ + public const KEY_PROVIDER = 'provider'; + public const KEY_MODELS = 'models'; + /** + * @var ProviderMetadata The provider metadata. + */ + protected \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider; + /** + * @var list The available models. + */ + protected array $models; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param ProviderMetadata $provider The provider metadata. + * @param list $models The available models. + * + * @throws InvalidArgumentException If models is not a list. + */ + public function __construct(\WordPress\AiClient\Providers\DTO\ProviderMetadata $provider, array $models) + { + if (!array_is_list($models)) { + throw new InvalidArgumentException('Models must be a list array.'); + } + $this->provider = $provider; + $this->models = $models; + } + /** + * Gets the provider metadata. + * + * @since 0.1.0 + * + * @return ProviderMetadata The provider metadata. + */ + public function getProvider(): \WordPress\AiClient\Providers\DTO\ProviderMetadata + { + return $this->provider; + } + /** + * Gets the available models. + * + * @since 0.1.0 + * + * @return list The available models. + */ + public function getModels(): array + { + return $this->models; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_PROVIDER => \WordPress\AiClient\Providers\DTO\ProviderMetadata::getJsonSchema(), self::KEY_MODELS => ['type' => 'array', 'items' => ModelMetadata::getJsonSchema(), 'description' => 'The available models for this provider.']], 'required' => [self::KEY_PROVIDER, self::KEY_MODELS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ProviderModelsMetadataArrayShape + */ + public function toArray(): array + { + return [self::KEY_PROVIDER => $this->provider->toArray(), self::KEY_MODELS => array_map(static fn(ModelMetadata $model): array => $model->toArray(), $this->models)]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_PROVIDER, self::KEY_MODELS]); + return new self(\WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray($array[self::KEY_PROVIDER]), array_map(static fn(array $modelData): ModelMetadata => ModelMetadata::fromArray($modelData), $array[self::KEY_MODELS])); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php b/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php new file mode 100644 index 0000000000000..c074f673b27bd --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Enums/ProviderTypeEnum.php @@ -0,0 +1,33 @@ +> The headers with original casing. + */ + private array $headers = []; + /** + * @var array Map of lowercase header names to actual header names. + */ + private array $headersMap = []; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param array> $headers Initial headers. + */ + public function __construct(array $headers = []) + { + foreach ($headers as $name => $value) { + $this->set($name, $value); + } + } + /** + * Gets a specific header value. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return list|null The header value(s) or null if not found. + */ + public function get(string $name): ?array + { + $lowerName = strtolower($name); + if (!isset($this->headersMap[$lowerName])) { + return null; + } + $actualName = $this->headersMap[$lowerName]; + return $this->headers[$actualName]; + } + /** + * Gets all headers. + * + * @since 0.1.0 + * + * @return array> All headers with their original casing. + */ + public function getAll(): array + { + return $this->headers; + } + /** + * Gets header values as a comma-separated string. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return string|null The header values as a comma-separated string or null if not found. + */ + public function getAsString(string $name): ?string + { + $values = $this->get($name); + return $values !== null ? implode(', ', $values) : null; + } + /** + * Checks if a header exists. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return bool True if the header exists, false otherwise. + */ + public function has(string $name): bool + { + return isset($this->headersMap[strtolower($name)]); + } + /** + * Sets a header value, replacing any existing value. + * + * @since 0.1.0 + * + * @param string $name The header name. + * @param string|list $value The header value(s). + * @return void + */ + private function set(string $name, $value): void + { + if (is_array($value)) { + $normalizedValues = array_values($value); + } else { + // Split comma-separated string into array + $normalizedValues = array_map('trim', explode(',', $value)); + } + $lowerName = strtolower($name); + // If header exists with different casing, remove the old casing + if (isset($this->headersMap[$lowerName])) { + $oldName = $this->headersMap[$lowerName]; + if ($oldName !== $name) { + unset($this->headers[$oldName]); + } + } + // Always use the new casing + $this->headers[$name] = $normalizedValues; + $this->headersMap[$lowerName] = $name; + } + /** + * Returns a new instance with the specified header. + * + * @since 0.1.0 + * + * @param string $name The header name. + * @param string|list $value The header value(s). + * @return self A new instance with the header. + */ + public function withHeader(string $name, $value): self + { + $new = clone $this; + $new->set($name, $value); + return $new; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php new file mode 100644 index 0000000000000..dddfb952a2449 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php @@ -0,0 +1,29 @@ + + */ +class ApiKeyRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface +{ + public const KEY_API_KEY = 'apiKey'; + /** + * @var string The API key used for authentication. + */ + protected string $apiKey; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $apiKey The API key used for authentication. + */ + public function __construct(string $apiKey) + { + $this->apiKey = $apiKey; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function authenticateRequest(\WordPress\AiClient\Providers\Http\DTO\Request $request): \WordPress\AiClient\Providers\Http\DTO\Request + { + // Add the API key to the request headers. + return $request->withHeader('Authorization', 'Bearer ' . $this->apiKey); + } + /** + * Gets the API key. + * + * @since 0.1.0 + * + * @return string The API key. + */ + public function getApiKey(): string + { + return $this->apiKey; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @since 0.1.0 + * + * @return ApiKeyRequestAuthenticationArrayShape + */ + public function toArray(): array + { + return [self::KEY_API_KEY => $this->apiKey]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_API_KEY]); + return new self($array[self::KEY_API_KEY]); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_API_KEY => ['type' => 'string', 'title' => 'API Key', 'description' => 'The API key used for authentication.']], 'required' => [self::KEY_API_KEY]]; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php new file mode 100644 index 0000000000000..211daf5ec7acd --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php @@ -0,0 +1,358 @@ +>, + * body?: string|null, + * options?: RequestOptionsArrayShape + * } + * + * @extends AbstractDataTransferObject + */ +class Request extends AbstractDataTransferObject +{ + public const KEY_METHOD = 'method'; + public const KEY_URI = 'uri'; + public const KEY_HEADERS = 'headers'; + public const KEY_BODY = 'body'; + public const KEY_OPTIONS = 'options'; + /** + * @var HttpMethodEnum The HTTP method. + */ + protected HttpMethodEnum $method; + /** + * @var string The request URI. + */ + protected string $uri; + /** + * @var HeadersCollection The request headers. + */ + protected HeadersCollection $headers; + /** + * @var array|null The request data (for query params or form data). + */ + protected ?array $data = null; + /** + * @var string|null The request body (raw string content). + */ + protected ?string $body = null; + /** + * @var RequestOptions|null Request transport options. + */ + protected ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param HttpMethodEnum $method The HTTP method. + * @param string $uri The request URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. + * @param RequestOptions|null $options The request transport options. + * + * @throws InvalidArgumentException If the URI is empty. + */ + public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null, ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null) + { + if (empty($uri)) { + throw new InvalidArgumentException('URI cannot be empty.'); + } + $this->method = $method; + $this->uri = $uri; + $this->headers = new HeadersCollection($headers); + // Separate data and body based on type + if (is_string($data)) { + $this->body = $data; + } elseif (is_array($data)) { + $this->data = $data; + } + $this->options = $options; + } + /** + * Gets the HTTP method. + * + * @since 0.1.0 + * + * @return HttpMethodEnum The HTTP method. + */ + public function getMethod(): HttpMethodEnum + { + return $this->method; + } + /** + * Gets the request URI. + * + * For GET requests with array data, appends the data as query parameters. + * + * @since 0.1.0 + * + * @return string The URI. + */ + public function getUri(): string + { + // If GET request with data, append as query parameters + if ($this->method === HttpMethodEnum::GET() && $this->data !== null && !empty($this->data)) { + $separator = str_contains($this->uri, '?') ? '&' : '?'; + return $this->uri . $separator . http_build_query($this->data); + } + return $this->uri; + } + /** + * Gets the request headers. + * + * @since 0.1.0 + * + * @return array> The headers. + */ + public function getHeaders(): array + { + return $this->headers->getAll(); + } + /** + * Gets a specific header value. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return list|null The header value(s) or null if not found. + */ + public function getHeader(string $name): ?array + { + return $this->headers->get($name); + } + /** + * Gets header values as a comma-separated string. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return string|null The header values as a comma-separated string, or null if not found. + */ + public function getHeaderAsString(string $name): ?string + { + return $this->headers->getAsString($name); + } + /** + * Checks if a header exists. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return bool True if the header exists, false otherwise. + */ + public function hasHeader(string $name): bool + { + return $this->headers->has($name); + } + /** + * Gets the request body. + * + * For GET requests, returns null. + * For POST/PUT/PATCH requests: + * - If body is set, returns it as-is + * - If data is set and Content-Type is JSON, returns JSON-encoded data + * - If data is set and Content-Type is form, returns URL-encoded data + * + * @since 0.1.0 + * + * @return string|null The body. + * @throws JsonException If the data cannot be encoded to JSON. + */ + public function getBody(): ?string + { + // GET requests don't have a body + if (!$this->method->hasBody()) { + return null; + } + // If body is set, return it as-is + if ($this->body !== null) { + return $this->body; + } + // If data is set, encode based on content type + if ($this->data !== null) { + $contentType = $this->getContentType(); + // JSON encoding + if ($contentType !== null && stripos($contentType, 'application/json') !== \false) { + return json_encode($this->data, \JSON_THROW_ON_ERROR); + } + // Default to URL encoding for forms + return http_build_query($this->data); + } + return null; + } + /** + * Gets the Content-Type header value. + * + * @since 0.1.0 + * + * @return string|null The Content-Type header value or null if not set. + */ + private function getContentType(): ?string + { + $values = $this->getHeader('Content-Type'); + return $values !== null ? $values[0] : null; + } + /** + * Returns a new instance with the specified header. + * + * @since 0.1.0 + * + * @param string $name The header name. + * @param string|list $value The header value(s). + * @return self A new instance with the header. + */ + public function withHeader(string $name, $value): self + { + $newHeaders = $this->headers->withHeader($name, $value); + $new = clone $this; + $new->headers = $newHeaders; + return $new; + } + /** + * Returns a new instance with the specified data. + * + * @since 0.1.0 + * + * @param string|array $data The request data. + * @return self A new instance with the data. + */ + public function withData($data): self + { + $new = clone $this; + if (is_string($data)) { + $new->body = $data; + $new->data = null; + } elseif (is_array($data)) { + $new->data = $data; + $new->body = null; + } else { + $new->data = null; + $new->body = null; + } + return $new; + } + /** + * Gets the request data array. + * + * @since 0.1.0 + * + * @return array|null The request data array. + */ + public function getData(): ?array + { + return $this->data; + } + /** + * Gets the request options. + * + * @since 0.2.0 + * + * @return RequestOptions|null Request transport options when configured. + */ + public function getOptions(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions + { + return $this->options; + } + /** + * Returns a new instance with the specified request options. + * + * @since 0.2.0 + * + * @param RequestOptions|null $options The request options to apply. + * @return self A new instance with the options. + */ + public function withOptions(?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options): self + { + $new = clone $this; + $new->options = $options; + return $new; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_METHOD => ['type' => 'string', 'description' => 'The HTTP method.'], self::KEY_URI => ['type' => 'string', 'description' => 'The request URI.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The request headers.'], self::KEY_BODY => ['type' => ['string'], 'description' => 'The request body.'], self::KEY_OPTIONS => \WordPress\AiClient\Providers\Http\DTO\RequestOptions::getJsonSchema()], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return RequestArrayShape + */ + public function toArray(): array + { + $array = [ + self::KEY_METHOD => $this->method->value, + self::KEY_URI => $this->getUri(), + // Include query params if GET with data + self::KEY_HEADERS => $this->headers->getAll(), + ]; + // Include body if present (getBody() handles the conversion) + $body = $this->getBody(); + if ($body !== null) { + $array[self::KEY_BODY] = $body; + } + if ($this->options !== null) { + $optionsArray = $this->options->toArray(); + if (!empty($optionsArray)) { + $array[self::KEY_OPTIONS] = $optionsArray; + } + } + return $array; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]); + return new self(HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], $array[self::KEY_BODY] ?? null, isset($array[self::KEY_OPTIONS]) ? \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray($array[self::KEY_OPTIONS]) : null); + } + /** + * Creates a Request instance from a PSR-7 RequestInterface. + * + * @since 0.2.0 + * + * @param RequestInterface $psrRequest The PSR-7 request to convert. + * @return self A new Request instance. + * @throws InvalidArgumentException If the HTTP method is not supported. + */ + public static function fromPsrRequest(RequestInterface $psrRequest): self + { + $method = HttpMethodEnum::from($psrRequest->getMethod()); + $uri = (string) $psrRequest->getUri(); + // Convert PSR-7 headers to array format expected by our constructor + /** @var array> $headers */ + $headers = $psrRequest->getHeaders(); + // Get body content + $body = $psrRequest->getBody()->getContents(); + $bodyOrData = !empty($body) ? $body : null; + return new self($method, $uri, $headers, $bodyOrData); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php new file mode 100644 index 0000000000000..c787c791df769 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php @@ -0,0 +1,204 @@ + + */ +class RequestOptions extends AbstractDataTransferObject +{ + public const KEY_TIMEOUT = 'timeout'; + public const KEY_CONNECT_TIMEOUT = 'connectTimeout'; + public const KEY_MAX_REDIRECTS = 'maxRedirects'; + /** + * @var float|null Maximum duration in seconds to wait for the full response. + */ + protected ?float $timeout = null; + /** + * @var float|null Maximum duration in seconds to wait for the initial connection. + */ + protected ?float $connectTimeout = null; + /** + * @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified. + */ + protected ?int $maxRedirects = null; + /** + * Sets the request timeout in seconds. + * + * @since 0.2.0 + * + * @param float|null $timeout Timeout in seconds. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. + */ + public function setTimeout(?float $timeout): void + { + $this->validateTimeout($timeout, self::KEY_TIMEOUT); + $this->timeout = $timeout; + } + /** + * Sets the connection timeout in seconds. + * + * @since 0.2.0 + * + * @param float|null $timeout Connection timeout in seconds. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. + */ + public function setConnectTimeout(?float $timeout): void + { + $this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT); + $this->connectTimeout = $timeout; + } + /** + * Sets the maximum number of redirects to follow. + * + * Set to 0 to disable redirects, null for unspecified, or a positive integer + * to enable redirects with a maximum count. + * + * @since 0.2.0 + * + * @param int|null $maxRedirects Maximum redirects to follow, or 0 to disable, or null for unspecified. + * @return void + * + * @throws InvalidArgumentException When redirect count is negative. + */ + public function setMaxRedirects(?int $maxRedirects): void + { + if ($maxRedirects !== null && $maxRedirects < 0) { + throw new InvalidArgumentException('Request option "maxRedirects" must be greater than or equal to 0.'); + } + $this->maxRedirects = $maxRedirects; + } + /** + * Gets the request timeout in seconds. + * + * @since 0.2.0 + * + * @return float|null Timeout in seconds. + */ + public function getTimeout(): ?float + { + return $this->timeout; + } + /** + * Gets the connection timeout in seconds. + * + * @since 0.2.0 + * + * @return float|null Connection timeout in seconds. + */ + public function getConnectTimeout(): ?float + { + return $this->connectTimeout; + } + /** + * Checks whether redirects are allowed. + * + * @since 0.2.0 + * + * @return bool|null True when redirects are allowed (maxRedirects > 0), + * false when disabled (maxRedirects = 0), + * null when unspecified (maxRedirects = null). + */ + public function allowsRedirects(): ?bool + { + if ($this->maxRedirects === null) { + return null; + } + return $this->maxRedirects > 0; + } + /** + * Gets the maximum number of redirects to follow. + * + * @since 0.2.0 + * + * @return int|null Maximum redirects or null when not specified. + */ + public function getMaxRedirects(): ?int + { + return $this->maxRedirects; + } + /** + * {@inheritDoc} + * + * @since 0.2.0 + * + * @return RequestOptionsArrayShape + */ + public function toArray(): array + { + $data = []; + if ($this->timeout !== null) { + $data[self::KEY_TIMEOUT] = $this->timeout; + } + if ($this->connectTimeout !== null) { + $data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout; + } + if ($this->maxRedirects !== null) { + $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.2.0 + */ + public static function fromArray(array $array): self + { + $instance = new self(); + if (isset($array[self::KEY_TIMEOUT])) { + $instance->setTimeout((float) $array[self::KEY_TIMEOUT]); + } + if (isset($array[self::KEY_CONNECT_TIMEOUT])) { + $instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]); + } + if (isset($array[self::KEY_MAX_REDIRECTS])) { + $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]); + } + return $instance; + } + /** + * {@inheritDoc} + * + * @since 0.2.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the full response.'], self::KEY_CONNECT_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the initial connection.'], self::KEY_MAX_REDIRECTS => ['type' => ['integer', 'null'], 'minimum' => 0, 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.']], 'additionalProperties' => \false]; + } + /** + * Validates timeout values. + * + * @since 0.2.0 + * + * @param float|null $value Timeout to validate. + * @param string $fieldName Field name for the error message. + * + * @throws InvalidArgumentException When timeout is negative. + */ + private function validateTimeout(?float $value, string $fieldName): void + { + if ($value !== null && $value < 0) { + throw new InvalidArgumentException(sprintf('Request option "%s" must be greater than or equal to 0.', $fieldName)); + } + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php new file mode 100644 index 0000000000000..73442ca456593 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Response.php @@ -0,0 +1,198 @@ +>, + * body?: string|null + * } + * + * @extends AbstractDataTransferObject + */ +class Response extends AbstractDataTransferObject +{ + public const KEY_STATUS_CODE = 'statusCode'; + public const KEY_HEADERS = 'headers'; + public const KEY_BODY = 'body'; + /** + * @var int The HTTP status code. + */ + protected int $statusCode; + /** + * @var HeadersCollection The response headers. + */ + protected HeadersCollection $headers; + /** + * @var string|null The response body. + */ + protected ?string $body; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param int $statusCode The HTTP status code. + * @param array> $headers The response headers. + * @param string|null $body The response body. + * + * @throws InvalidArgumentException If the status code is invalid. + */ + public function __construct(int $statusCode, array $headers, ?string $body = null) + { + if ($statusCode < 100 || $statusCode >= 600) { + throw new InvalidArgumentException('Invalid HTTP status code: ' . $statusCode); + } + $this->statusCode = $statusCode; + $this->headers = new HeadersCollection($headers); + $this->body = $body; + } + /** + * Gets the HTTP status code. + * + * @since 0.1.0 + * + * @return int The status code. + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + /** + * Gets the response headers. + * + * @since 0.1.0 + * + * @return array> The headers. + */ + public function getHeaders(): array + { + return $this->headers->getAll(); + } + /** + * Gets a specific header value. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return list|null The header value(s) or null if not found. + */ + public function getHeader(string $name): ?array + { + return $this->headers->get($name); + } + /** + * Gets header values as a comma-separated string. + * + * @since 0.1.0 + * + * @param string $name The header name (case-insensitive). + * @return string|null The header values as a comma-separated string or null if not found. + */ + public function getHeaderAsString(string $name): ?string + { + return $this->headers->getAsString($name); + } + /** + * Gets the response body. + * + * @since 0.1.0 + * + * @return string|null The body. + */ + public function getBody(): ?string + { + return $this->body; + } + /** + * Checks if the response has a header. + * + * @since 0.1.0 + * + * @param string $name The header name. + * @return bool True if the header exists, false otherwise. + */ + public function hasHeader(string $name): bool + { + return $this->headers->has($name); + } + /** + * Checks if the response indicates success. + * + * @since 0.1.0 + * + * @return bool True if status code is 2xx, false otherwise. + */ + public function isSuccessful(): bool + { + return $this->statusCode >= 200 && $this->statusCode < 300; + } + /** + * Gets the response data as an array. + * + * Attempts to decode the body as JSON. Returns null if the body + * is empty or not valid JSON. + * + * @since 0.1.0 + * + * @return array|null The decoded data or null. + */ + public function getData(): ?array + { + if ($this->body === null || $this->body === '') { + return null; + } + $data = json_decode($this->body, \true); + if (json_last_error() !== \JSON_ERROR_NONE) { + return null; + } + /** @var array|null $data */ + return is_array($data) ? $data : null; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_STATUS_CODE => ['type' => 'integer', 'minimum' => 100, 'maximum' => 599, 'description' => 'The HTTP status code.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The response headers.'], self::KEY_BODY => ['type' => ['string', 'null'], 'description' => 'The response body.']], 'required' => [self::KEY_STATUS_CODE, self::KEY_HEADERS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ResponseArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_STATUS_CODE => $this->statusCode, self::KEY_HEADERS => $this->headers->getAll()]; + if ($this->body !== null) { + $data[self::KEY_BODY] = $this->body; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_STATUS_CODE, self::KEY_HEADERS]); + return new self($array[self::KEY_STATUS_CODE], $array[self::KEY_HEADERS], $array[self::KEY_BODY] ?? null); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php new file mode 100644 index 0000000000000..42520c949cd6e --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/HttpMethodEnum.php @@ -0,0 +1,110 @@ +value, [self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE], \true); + } + /** + * Checks if this method typically has a request body. + * + * @since 0.1.0 + * + * @return bool True if the method typically has a body, false otherwise. + */ + public function hasBody(): bool + { + return in_array($this->value, [self::POST, self::PUT, self::PATCH], \true); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php new file mode 100644 index 0000000000000..e43eb027579c4 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Enums/RequestAuthenticationMethod.php @@ -0,0 +1,39 @@ + The implementation class. + * + * @phpstan-ignore missingType.generics + */ + public function getImplementationClass(): string + { + // At the moment, this is the only supported method. + // Once more methods are available, add conditionals here for each method. + return ApiKeyRequestAuthentication::class; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php new file mode 100644 index 0000000000000..569e76e066a68 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ClientException.php @@ -0,0 +1,68 @@ +request === null) { + throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.'); + } + return $this->request; + } + /** + * Creates a ClientException from a client error response (4xx). + * + * This method extracts error details from common API response formats + * and creates an exception with a descriptive message and status code. + * + * @since 0.2.0 + * + * @param Response $response The HTTP response that failed. + * @return self + */ + public static function fromClientErrorResponse(Response $response): self + { + $statusCode = $response->getStatusCode(); + $statusTexts = [400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests']; + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf('Client error (%d): Request was rejected due to client-side issue', $statusCode); + } + // Extract error message from response data using centralized utility + $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); + if ($extractedError !== null) { + $errorMessage .= ' - ' . $extractedError; + } + return new self($errorMessage, $statusCode); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php new file mode 100644 index 0000000000000..8b4977eb14738 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php @@ -0,0 +1,57 @@ +request === null) { + throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.'); + } + return $this->request; + } + /** + * Creates a NetworkException from a PSR-18 network exception. + * + * @since 0.2.0 + * + * @param RequestInterface $psrRequest The PSR-7 request that failed. + * @param \Throwable $networkException The PSR-18 network exception. + * @return self + */ + public static function fromPsr18NetworkException(RequestInterface $psrRequest, \Throwable $networkException): self + { + $request = Request::fromPsrRequest($psrRequest); + $message = sprintf('Network error occurred while sending request to %s: %s', $request->getUri(), $networkException->getMessage()); + $exception = new self($message, 0, $networkException); + $exception->request = $request; + return $exception; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php new file mode 100644 index 0000000000000..0b21fe5219c25 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/RedirectException.php @@ -0,0 +1,47 @@ +getStatusCode(); + $statusTexts = [300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 307 => 'Temporary Redirect', 308 => 'Permanent Redirect']; + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf('Redirect error (%d): Request needs to be retried at a different location', $statusCode); + } + // Try to extract the redirect location from headers + $locationValues = $response->getHeader('Location'); + if ($locationValues !== null && !empty($locationValues)) { + $location = $locationValues[0]; + $errorMessage .= ' - Location: ' . $location; + } + return new self($errorMessage, $statusCode); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php new file mode 100644 index 0000000000000..3e2dd07e43014 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ResponseException.php @@ -0,0 +1,46 @@ +getStatusCode(); + $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage']; + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf('Server error (%d): Request was rejected due to server-side issue', $statusCode); + } + // Extract error message from response data using centralized utility + $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); + if ($extractedError !== null) { + $errorMessage .= ' - ' . $extractedError; + } + return new self($errorMessage, $response->getStatusCode()); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php new file mode 100644 index 0000000000000..0dc8e56c82a18 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php @@ -0,0 +1,267 @@ +client = $client ?: Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?: Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory(); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * @since 0.2.0 Added optional RequestOptions parameter and ClientWithOptions support. + */ + public function send(Request $request, ?RequestOptions $options = null): Response + { + $psr7Request = $this->convertToPsr7Request($request); + // Merge request options with parameter options, with parameter options taking precedence + $mergedOptions = $this->mergeOptions($request->getOptions(), $options); + try { + $hasOptions = $mergedOptions !== null; + if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) { + $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions); + } elseif ($hasOptions && $this->isGuzzleClient($this->client)) { + $psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions); + } else { + $psr7Response = $this->client->sendRequest($psr7Request); + } + } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { + throw NetworkException::fromPsr18NetworkException($psr7Request, $e); + } catch (\Psr\Http\Client\ClientExceptionInterface $e) { + // Handle other PSR-18 client exceptions that are not network-related + throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e); + } + return $this->convertFromPsr7Response($psr7Response); + } + /** + * Merges request options with parameter options taking precedence. + * + * @since 0.2.0 + * + * @param RequestOptions|null $requestOptions Options from the Request object. + * @param RequestOptions|null $parameterOptions Options passed as method parameter. + * @return RequestOptions|null Merged options, or null if both are null. + */ + private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $parameterOptions): ?RequestOptions + { + // If no options at all, return null + if ($requestOptions === null && $parameterOptions === null) { + return null; + } + // If only one set of options exists, return it + if ($requestOptions === null) { + return $parameterOptions; + } + if ($parameterOptions === null) { + return $requestOptions; + } + // Both exist, merge them with parameter options taking precedence + $merged = new RequestOptions(); + // Start with request options (lower precedence) + if ($requestOptions->getTimeout() !== null) { + $merged->setTimeout($requestOptions->getTimeout()); + } + if ($requestOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($requestOptions->getConnectTimeout()); + } + if ($requestOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($requestOptions->getMaxRedirects()); + } + // Override with parameter options (higher precedence) + if ($parameterOptions->getTimeout() !== null) { + $merged->setTimeout($parameterOptions->getTimeout()); + } + if ($parameterOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($parameterOptions->getConnectTimeout()); + } + if ($parameterOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($parameterOptions->getMaxRedirects()); + } + return $merged; + } + /** + * Determines if the underlying client matches the Guzzle client shape. + * + * @since 0.2.0 + * + * @param ClientInterface $client The HTTP client instance. + * @return bool True when the client exposes Guzzle's send signature. + */ + private function isGuzzleClient(ClientInterface $client): bool + { + $reflection = new \ReflectionObject($client); + if (!is_callable([$client, 'send'])) { + return \false; + } + if (!$reflection->hasMethod('send')) { + return \false; + } + $method = $reflection->getMethod('send'); + if (!$method->isPublic() || $method->isStatic()) { + return \false; + } + $parameters = $method->getParameters(); + if (count($parameters) < 2) { + return \false; + } + $firstParameter = $parameters[0]->getType(); + if (!$firstParameter instanceof \ReflectionNamedType || $firstParameter->isBuiltin()) { + return \false; + } + if (!is_a($firstParameter->getName(), RequestInterface::class, \true)) { + return \false; + } + $secondParameter = $parameters[1]; + $secondType = $secondParameter->getType(); + if (!$secondType instanceof \ReflectionNamedType || $secondType->getName() !== 'array') { + return \false; + } + return \true; + } + /** + * Sends a request using a Guzzle-compatible client. + * + * @since 0.2.0 + * + * @param RequestInterface $request The PSR-7 request to send. + * @param RequestOptions $options The request options. + * @return ResponseInterface The PSR-7 response received. + */ + private function sendWithGuzzle(RequestInterface $request, RequestOptions $options): ResponseInterface + { + $guzzleOptions = $this->buildGuzzleOptions($options); + /** @var callable $callable */ + $callable = [$this->client, 'send']; + /** @var ResponseInterface $response */ + $response = $callable($request, $guzzleOptions); + return $response; + } + /** + * Converts request options to a Guzzle-compatible options array. + * + * @since 0.2.0 + * + * @param RequestOptions $options The request options. + * @return array Guzzle-compatible options. + */ + private function buildGuzzleOptions(RequestOptions $options): array + { + $guzzleOptions = []; + $timeout = $options->getTimeout(); + if ($timeout !== null) { + $guzzleOptions['timeout'] = $timeout; + } + $connectTimeout = $options->getConnectTimeout(); + if ($connectTimeout !== null) { + $guzzleOptions['connect_timeout'] = $connectTimeout; + } + $allowRedirects = $options->allowsRedirects(); + if ($allowRedirects !== null) { + if ($allowRedirects) { + $redirectOptions = []; + $maxRedirects = $options->getMaxRedirects(); + if ($maxRedirects !== null) { + $redirectOptions['max'] = $maxRedirects; + } + $guzzleOptions['allow_redirects'] = !empty($redirectOptions) ? $redirectOptions : \true; + } else { + $guzzleOptions['allow_redirects'] = \false; + } + } + return $guzzleOptions; + } + /** + * Converts a custom Request to a PSR-7 request. + * + * @since 0.1.0 + * + * @param Request $request The custom request. + * @return RequestInterface The PSR-7 request. + */ + private function convertToPsr7Request(Request $request): RequestInterface + { + $psr7Request = $this->requestFactory->createRequest($request->getMethod()->value, $request->getUri()); + // Add headers + foreach ($request->getHeaders() as $name => $values) { + foreach ($values as $value) { + $psr7Request = $psr7Request->withAddedHeader($name, $value); + } + } + // Add body if present + $body = $request->getBody(); + if ($body !== null) { + $stream = $this->streamFactory->createStream($body); + $psr7Request = $psr7Request->withBody($stream); + } + return $psr7Request; + } + /** + * Converts a PSR-7 response to a custom Response. + * + * @since 0.1.0 + * + * @param ResponseInterface $psr7Response The PSR-7 response. + * @return Response The custom response. + */ + private function convertFromPsr7Response(ResponseInterface $psr7Response): Response + { + $body = (string) $psr7Response->getBody(); + // PSR-7 always returns headers as arrays, but HeadersCollection handles this + return new Response( + $psr7Response->getStatusCode(), + $psr7Response->getHeaders(), + // @phpstan-ignore-line + $body === '' ? null : $body + ); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php new file mode 100644 index 0000000000000..f2927f7e4e611 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporterFactory.php @@ -0,0 +1,33 @@ +httpTransporter = $httpTransporter; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getHttpTransporter(): HttpTransporterInterface + { + if ($this->httpTransporter === null) { + throw new RuntimeException('HttpTransporterInterface instance not set. Make sure you use the AiClient class for all requests.'); + } + return $this->httpTransporter; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php b/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php new file mode 100644 index 0000000000000..12c13541709ea --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php @@ -0,0 +1,40 @@ +requestAuthentication = $requestAuthentication; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + if ($this->requestAuthentication === null) { + throw new RuntimeException('RequestAuthenticationInterface instance not set. ' . 'Make sure you use the AiClient class for all requests.'); + } + return $this->requestAuthentication; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php b/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php new file mode 100644 index 0000000000000..8b71f5be77be4 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Util/ErrorMessageExtractor.php @@ -0,0 +1,53 @@ +isSuccessful()) { + return; + } + $statusCode = $response->getStatusCode(); + // 3xx Redirect Responses + if ($statusCode >= 300 && $statusCode < 400) { + throw RedirectException::fromRedirectResponse($response); + } + // 4xx Client Errors + if ($statusCode >= 400 && $statusCode < 500) { + throw ClientException::fromClientErrorResponse($response); + } + // 5xx Server Errors + if ($statusCode >= 500 && $statusCode < 600) { + throw ServerException::fromServerErrorResponse($response); + } + throw new \RuntimeException(sprintf('Response returned invalid status code: %s', $response->getStatusCode())); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php new file mode 100644 index 0000000000000..45abe5ab51fa7 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/Contracts/ModelInterface.php @@ -0,0 +1,52 @@ +, + * systemInstruction?: string, + * candidateCount?: int, + * maxTokens?: int, + * temperature?: float, + * topP?: float, + * topK?: int, + * stopSequences?: list, + * presencePenalty?: float, + * frequencyPenalty?: float, + * logprobs?: bool, + * topLogprobs?: int, + * functionDeclarations?: list, + * webSearch?: WebSearchArrayShape, + * outputFileType?: string, + * outputMimeType?: string, + * outputSchema?: array, + * outputMediaOrientation?: string, + * outputMediaAspectRatio?: string, + * outputSpeechVoice?: string, + * customOptions?: array + * } + * + * @extends AbstractDataTransferObject + */ +class ModelConfig extends AbstractDataTransferObject +{ + public const KEY_OUTPUT_MODALITIES = 'outputModalities'; + public const KEY_SYSTEM_INSTRUCTION = 'systemInstruction'; + public const KEY_CANDIDATE_COUNT = 'candidateCount'; + public const KEY_MAX_TOKENS = 'maxTokens'; + public const KEY_TEMPERATURE = 'temperature'; + public const KEY_TOP_P = 'topP'; + public const KEY_TOP_K = 'topK'; + public const KEY_STOP_SEQUENCES = 'stopSequences'; + public const KEY_PRESENCE_PENALTY = 'presencePenalty'; + public const KEY_FREQUENCY_PENALTY = 'frequencyPenalty'; + public const KEY_LOGPROBS = 'logprobs'; + public const KEY_TOP_LOGPROBS = 'topLogprobs'; + public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations'; + public const KEY_WEB_SEARCH = 'webSearch'; + public const KEY_OUTPUT_FILE_TYPE = 'outputFileType'; + public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType'; + public const KEY_OUTPUT_SCHEMA = 'outputSchema'; + public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation'; + public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; + public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; + public const KEY_CUSTOM_OPTIONS = 'customOptions'; + /* + * Note: This key is not an actual model config key, but specified here for convenience. + * It is relevant for model discovery, to determine which models support which input modalities. + * The actual input modalities are part of the message sent to the model, not the model config. + */ + public const KEY_INPUT_MODALITIES = 'inputModalities'; + /** + * @var list|null Output modalities for the model. + */ + protected ?array $outputModalities = null; + /** + * @var string|null System instruction for the model. + */ + protected ?string $systemInstruction = null; + /** + * @var int|null Number of response candidates to generate. + */ + protected ?int $candidateCount = null; + /** + * @var int|null Maximum number of tokens to generate. + */ + protected ?int $maxTokens = null; + /** + * @var float|null Temperature for randomness (0.0 to 2.0). + */ + protected ?float $temperature = null; + /** + * @var float|null Top-p nucleus sampling parameter. + */ + protected ?float $topP = null; + /** + * @var int|null Top-k sampling parameter. + */ + protected ?int $topK = null; + /** + * @var list|null Stop sequences. + */ + protected ?array $stopSequences = null; + /** + * @var float|null Presence penalty for reducing repetition. + */ + protected ?float $presencePenalty = null; + /** + * @var float|null Frequency penalty for reducing repetition. + */ + protected ?float $frequencyPenalty = null; + /** + * @var bool|null Whether to return log probabilities. + */ + protected ?bool $logprobs = null; + /** + * @var int|null Number of top log probabilities to return. + */ + protected ?int $topLogprobs = null; + /** + * @var list|null Function declarations available to the model. + */ + protected ?array $functionDeclarations = null; + /** + * @var WebSearch|null Web search configuration for the model. + */ + protected ?WebSearch $webSearch = null; + /** + * @var FileTypeEnum|null Output file type. + */ + protected ?FileTypeEnum $outputFileType = null; + /** + * @var string|null Output MIME type. + */ + protected ?string $outputMimeType = null; + /** + * @var array|null Output schema (JSON schema). + */ + protected ?array $outputSchema = null; + /** + * @var MediaOrientationEnum|null Output media orientation. + */ + protected ?MediaOrientationEnum $outputMediaOrientation = null; + /** + * @var string|null Output media aspect ratio (e.g. 3:2, 16:9). + */ + protected ?string $outputMediaAspectRatio = null; + /** + * @var string|null Output speech voice. + */ + protected ?string $outputSpeechVoice = null; + /** + * @var array Custom provider-specific options. + */ + protected array $customOptions = []; + /** + * Sets the output modalities. + * + * @since 0.1.0 + * + * @param list $outputModalities The output modalities. + * + * @throws InvalidArgumentException If the array is not a list. + */ + public function setOutputModalities(array $outputModalities): void + { + if (!array_is_list($outputModalities)) { + throw new InvalidArgumentException('Output modalities must be a list array.'); + } + $this->outputModalities = $outputModalities; + } + /** + * Gets the output modalities. + * + * @since 0.1.0 + * + * @return list|null The output modalities. + */ + public function getOutputModalities(): ?array + { + return $this->outputModalities; + } + /** + * Sets the system instruction. + * + * @since 0.1.0 + * + * @param string $systemInstruction The system instruction. + */ + public function setSystemInstruction(string $systemInstruction): void + { + $this->systemInstruction = $systemInstruction; + } + /** + * Gets the system instruction. + * + * @since 0.1.0 + * + * @return string|null The system instruction. + */ + public function getSystemInstruction(): ?string + { + return $this->systemInstruction; + } + /** + * Sets the candidate count. + * + * @since 0.1.0 + * + * @param int $candidateCount The candidate count. + */ + public function setCandidateCount(int $candidateCount): void + { + $this->candidateCount = $candidateCount; + } + /** + * Gets the candidate count. + * + * @since 0.1.0 + * + * @return int|null The candidate count. + */ + public function getCandidateCount(): ?int + { + return $this->candidateCount; + } + /** + * Sets the maximum tokens. + * + * @since 0.1.0 + * + * @param int $maxTokens The maximum tokens. + */ + public function setMaxTokens(int $maxTokens): void + { + $this->maxTokens = $maxTokens; + } + /** + * Gets the maximum tokens. + * + * @since 0.1.0 + * + * @return int|null The maximum tokens. + */ + public function getMaxTokens(): ?int + { + return $this->maxTokens; + } + /** + * Sets the temperature. + * + * @since 0.1.0 + * + * @param float $temperature The temperature. + */ + public function setTemperature(float $temperature): void + { + $this->temperature = $temperature; + } + /** + * Gets the temperature. + * + * @since 0.1.0 + * + * @return float|null The temperature. + */ + public function getTemperature(): ?float + { + return $this->temperature; + } + /** + * Sets the top-p parameter. + * + * @since 0.1.0 + * + * @param float $topP The top-p parameter. + */ + public function setTopP(float $topP): void + { + $this->topP = $topP; + } + /** + * Gets the top-p parameter. + * + * @since 0.1.0 + * + * @return float|null The top-p parameter. + */ + public function getTopP(): ?float + { + return $this->topP; + } + /** + * Sets the top-k parameter. + * + * @since 0.1.0 + * + * @param int $topK The top-k parameter. + */ + public function setTopK(int $topK): void + { + $this->topK = $topK; + } + /** + * Gets the top-k parameter. + * + * @since 0.1.0 + * + * @return int|null The top-k parameter. + */ + public function getTopK(): ?int + { + return $this->topK; + } + /** + * Sets the stop sequences. + * + * @since 0.1.0 + * + * @param list $stopSequences The stop sequences. + * + * @throws InvalidArgumentException If the array is not a list. + */ + public function setStopSequences(array $stopSequences): void + { + if (!array_is_list($stopSequences)) { + throw new InvalidArgumentException('Stop sequences must be a list array.'); + } + $this->stopSequences = $stopSequences; + } + /** + * Gets the stop sequences. + * + * @since 0.1.0 + * + * @return list|null The stop sequences. + */ + public function getStopSequences(): ?array + { + return $this->stopSequences; + } + /** + * Sets the presence penalty. + * + * @since 0.1.0 + * + * @param float $presencePenalty The presence penalty. + */ + public function setPresencePenalty(float $presencePenalty): void + { + $this->presencePenalty = $presencePenalty; + } + /** + * Gets the presence penalty. + * + * @since 0.1.0 + * + * @return float|null The presence penalty. + */ + public function getPresencePenalty(): ?float + { + return $this->presencePenalty; + } + /** + * Sets the frequency penalty. + * + * @since 0.1.0 + * + * @param float $frequencyPenalty The frequency penalty. + */ + public function setFrequencyPenalty(float $frequencyPenalty): void + { + $this->frequencyPenalty = $frequencyPenalty; + } + /** + * Gets the frequency penalty. + * + * @since 0.1.0 + * + * @return float|null The frequency penalty. + */ + public function getFrequencyPenalty(): ?float + { + return $this->frequencyPenalty; + } + /** + * Sets whether to return log probabilities. + * + * @since 0.1.0 + * + * @param bool $logprobs Whether to return log probabilities. + */ + public function setLogprobs(bool $logprobs): void + { + $this->logprobs = $logprobs; + } + /** + * Gets whether to return log probabilities. + * + * @since 0.1.0 + * + * @return bool|null Whether to return log probabilities. + */ + public function getLogprobs(): ?bool + { + return $this->logprobs; + } + /** + * Sets the number of top log probabilities to return. + * + * @since 0.1.0 + * + * @param int $topLogprobs The number of top log probabilities. + */ + public function setTopLogprobs(int $topLogprobs): void + { + $this->topLogprobs = $topLogprobs; + } + /** + * Gets the number of top log probabilities to return. + * + * @since 0.1.0 + * + * @return int|null The number of top log probabilities. + */ + public function getTopLogprobs(): ?int + { + return $this->topLogprobs; + } + /** + * Sets the function declarations. + * + * @since 0.1.0 + * + * @param list $function_declarations The function declarations. + * + * @throws InvalidArgumentException If the array is not a list. + */ + public function setFunctionDeclarations(array $function_declarations): void + { + if (!array_is_list($function_declarations)) { + throw new InvalidArgumentException('Function declarations must be a list array.'); + } + $this->functionDeclarations = $function_declarations; + } + /** + * Gets the function declarations. + * + * @since 0.1.0 + * + * @return list|null The function declarations. + */ + public function getFunctionDeclarations(): ?array + { + return $this->functionDeclarations; + } + /** + * Sets the web search configuration. + * + * @since 0.1.0 + * + * @param WebSearch $web_search The web search configuration. + */ + public function setWebSearch(WebSearch $web_search): void + { + $this->webSearch = $web_search; + } + /** + * Gets the web search configuration. + * + * @since 0.1.0 + * + * @return WebSearch|null The web search configuration. + */ + public function getWebSearch(): ?WebSearch + { + return $this->webSearch; + } + /** + * Sets the output file type. + * + * @since 0.1.0 + * + * @param FileTypeEnum $outputFileType The output file type. + */ + public function setOutputFileType(FileTypeEnum $outputFileType): void + { + $this->outputFileType = $outputFileType; + } + /** + * Gets the output file type. + * + * @since 0.1.0 + * + * @return FileTypeEnum|null The output file type. + */ + public function getOutputFileType(): ?FileTypeEnum + { + return $this->outputFileType; + } + /** + * Sets the output MIME type. + * + * @since 0.1.0 + * + * @param string $outputMimeType The output MIME type. + */ + public function setOutputMimeType(string $outputMimeType): void + { + $this->outputMimeType = $outputMimeType; + } + /** + * Gets the output MIME type. + * + * @since 0.1.0 + * + * @return string|null The output MIME type. + */ + public function getOutputMimeType(): ?string + { + return $this->outputMimeType; + } + /** + * Sets the output schema. + * + * When setting an output schema, this method automatically sets + * the output MIME type to "application/json" if not already set. + * + * @since 0.1.0 + * + * @param array $outputSchema The output schema (JSON schema). + */ + public function setOutputSchema(array $outputSchema): void + { + $this->outputSchema = $outputSchema; + // Automatically set outputMimeType to application/json when schema is provided + if ($this->outputMimeType === null) { + $this->outputMimeType = 'application/json'; + } + } + /** + * Gets the output schema. + * + * @since 0.1.0 + * + * @return array|null The output schema. + */ + public function getOutputSchema(): ?array + { + return $this->outputSchema; + } + /** + * Sets the output media orientation. + * + * @since 0.1.0 + * + * @param MediaOrientationEnum $outputMediaOrientation The output media orientation. + */ + public function setOutputMediaOrientation(MediaOrientationEnum $outputMediaOrientation): void + { + if ($this->outputMediaAspectRatio) { + $this->validateMediaOrientationAspectRatioCompatibility($outputMediaOrientation, $this->outputMediaAspectRatio); + } + $this->outputMediaOrientation = $outputMediaOrientation; + } + /** + * Gets the output media orientation. + * + * @since 0.1.0 + * + * @return MediaOrientationEnum|null The output media orientation. + */ + public function getOutputMediaOrientation(): ?MediaOrientationEnum + { + return $this->outputMediaOrientation; + } + /** + * Sets the output media aspect ratio. + * + * If set, this supersedes the output media orientation, as it is a more specific configuration. + * + * @since 0.1.0 + * + * @param string $outputMediaAspectRatio The output media aspect ratio (e.g. 3:2, 16:9). + */ + public function setOutputMediaAspectRatio(string $outputMediaAspectRatio): void + { + if (!preg_match('/^\d+:\d+$/', $outputMediaAspectRatio)) { + throw new InvalidArgumentException('Output media aspect ratio must be in the format "width:height" (e.g. 3:2, 16:9).'); + } + if ($this->outputMediaOrientation) { + $this->validateMediaOrientationAspectRatioCompatibility($this->outputMediaOrientation, $outputMediaAspectRatio); + } + $this->outputMediaAspectRatio = $outputMediaAspectRatio; + } + /** + * Gets the output media aspect ratio. + * + * @since 0.1.0 + * + * @return string|null The output media aspect ratio (e.g. 3:2, 16:9). + */ + public function getOutputMediaAspectRatio(): ?string + { + return $this->outputMediaAspectRatio; + } + /** + * Validates that the given media orientation and aspect ratio values do not conflict with each other. + * + * @since 0.4.0 + * + * @param MediaOrientationEnum $orientation The desired media orientation. + * @param string $aspectRatio The desired media aspect ratio. + */ + protected function validateMediaOrientationAspectRatioCompatibility(MediaOrientationEnum $orientation, string $aspectRatio): void + { + if ($orientation->isSquare() && $aspectRatio !== '1:1') { + throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the square orientation.'); + } + $aspectRatioParts = explode(':', $aspectRatio); + if ($orientation->isLandscape() && $aspectRatioParts[0] <= $aspectRatioParts[1]) { + throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the landscape orientation.'); + } + if ($orientation->isPortrait() && $aspectRatioParts[0] >= $aspectRatioParts[1]) { + throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the portrait orientation.'); + } + } + /** + * Sets the output speech voice. + * + * @since 0.1.0 + * + * @param string $outputSpeechVoice The output speech voice. + */ + public function setOutputSpeechVoice(string $outputSpeechVoice): void + { + $this->outputSpeechVoice = $outputSpeechVoice; + } + /** + * Gets the output speech voice. + * + * @since 0.1.0 + * + * @return string|null The output speech voice. + */ + public function getOutputSpeechVoice(): ?string + { + return $this->outputSpeechVoice; + } + /** + * Sets a single custom option. + * + * @since 0.1.0 + * + * @param string $key The option key. + * @param mixed $value The option value. + */ + public function setCustomOption(string $key, $value): void + { + $this->customOptions[$key] = $value; + } + /** + * Sets the custom options. + * + * @since 0.1.0 + * + * @param array $customOptions The custom options. + */ + public function setCustomOptions(array $customOptions): void + { + $this->customOptions = $customOptions; + } + /** + * Gets the custom options. + * + * @since 0.1.0 + * + * @return array The custom options. + */ + public function getCustomOptions(): array + { + return $this->customOptions; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ModelConfigArrayShape + */ + public function toArray(): array + { + $data = []; + if ($this->outputModalities !== null) { + $data[self::KEY_OUTPUT_MODALITIES] = array_map(static function (ModalityEnum $modality): string { + return $modality->value; + }, $this->outputModalities); + } + if ($this->systemInstruction !== null) { + $data[self::KEY_SYSTEM_INSTRUCTION] = $this->systemInstruction; + } + if ($this->candidateCount !== null) { + $data[self::KEY_CANDIDATE_COUNT] = $this->candidateCount; + } + if ($this->maxTokens !== null) { + $data[self::KEY_MAX_TOKENS] = $this->maxTokens; + } + if ($this->temperature !== null) { + $data[self::KEY_TEMPERATURE] = $this->temperature; + } + if ($this->topP !== null) { + $data[self::KEY_TOP_P] = $this->topP; + } + if ($this->topK !== null) { + $data[self::KEY_TOP_K] = $this->topK; + } + if ($this->stopSequences !== null) { + $data[self::KEY_STOP_SEQUENCES] = $this->stopSequences; + } + if ($this->presencePenalty !== null) { + $data[self::KEY_PRESENCE_PENALTY] = $this->presencePenalty; + } + if ($this->frequencyPenalty !== null) { + $data[self::KEY_FREQUENCY_PENALTY] = $this->frequencyPenalty; + } + if ($this->logprobs !== null) { + $data[self::KEY_LOGPROBS] = $this->logprobs; + } + if ($this->topLogprobs !== null) { + $data[self::KEY_TOP_LOGPROBS] = $this->topLogprobs; + } + if ($this->functionDeclarations !== null) { + $data[self::KEY_FUNCTION_DECLARATIONS] = array_map(static function (FunctionDeclaration $function_declaration): array { + return $function_declaration->toArray(); + }, $this->functionDeclarations); + } + if ($this->webSearch !== null) { + $data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray(); + } + if ($this->outputFileType !== null) { + $data[self::KEY_OUTPUT_FILE_TYPE] = $this->outputFileType->value; + } + if ($this->outputMimeType !== null) { + $data[self::KEY_OUTPUT_MIME_TYPE] = $this->outputMimeType; + } + if ($this->outputSchema !== null) { + $data[self::KEY_OUTPUT_SCHEMA] = $this->outputSchema; + } + if ($this->outputMediaOrientation !== null) { + $data[self::KEY_OUTPUT_MEDIA_ORIENTATION] = $this->outputMediaOrientation->value; + } + if ($this->outputMediaAspectRatio !== null) { + $data[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO] = $this->outputMediaAspectRatio; + } + if ($this->outputSpeechVoice !== null) { + $data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice; + } + if (!empty($this->customOptions)) { + $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + $config = new self(); + if (isset($array[self::KEY_OUTPUT_MODALITIES])) { + $config->setOutputModalities(array_map(static fn(string $modality): ModalityEnum => ModalityEnum::from($modality), $array[self::KEY_OUTPUT_MODALITIES])); + } + if (isset($array[self::KEY_SYSTEM_INSTRUCTION])) { + $config->setSystemInstruction($array[self::KEY_SYSTEM_INSTRUCTION]); + } + if (isset($array[self::KEY_CANDIDATE_COUNT])) { + $config->setCandidateCount($array[self::KEY_CANDIDATE_COUNT]); + } + if (isset($array[self::KEY_MAX_TOKENS])) { + $config->setMaxTokens($array[self::KEY_MAX_TOKENS]); + } + if (isset($array[self::KEY_TEMPERATURE])) { + $config->setTemperature($array[self::KEY_TEMPERATURE]); + } + if (isset($array[self::KEY_TOP_P])) { + $config->setTopP($array[self::KEY_TOP_P]); + } + if (isset($array[self::KEY_TOP_K])) { + $config->setTopK($array[self::KEY_TOP_K]); + } + if (isset($array[self::KEY_STOP_SEQUENCES])) { + $config->setStopSequences($array[self::KEY_STOP_SEQUENCES]); + } + if (isset($array[self::KEY_PRESENCE_PENALTY])) { + $config->setPresencePenalty($array[self::KEY_PRESENCE_PENALTY]); + } + if (isset($array[self::KEY_FREQUENCY_PENALTY])) { + $config->setFrequencyPenalty($array[self::KEY_FREQUENCY_PENALTY]); + } + if (isset($array[self::KEY_LOGPROBS])) { + $config->setLogprobs($array[self::KEY_LOGPROBS]); + } + if (isset($array[self::KEY_TOP_LOGPROBS])) { + $config->setTopLogprobs($array[self::KEY_TOP_LOGPROBS]); + } + if (isset($array[self::KEY_FUNCTION_DECLARATIONS])) { + $config->setFunctionDeclarations(array_map(static function (array $function_declaration_data): FunctionDeclaration { + return FunctionDeclaration::fromArray($function_declaration_data); + }, $array[self::KEY_FUNCTION_DECLARATIONS])); + } + if (isset($array[self::KEY_WEB_SEARCH])) { + $config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH])); + } + if (isset($array[self::KEY_OUTPUT_FILE_TYPE])) { + $config->setOutputFileType(FileTypeEnum::from($array[self::KEY_OUTPUT_FILE_TYPE])); + } + if (isset($array[self::KEY_OUTPUT_MIME_TYPE])) { + $config->setOutputMimeType($array[self::KEY_OUTPUT_MIME_TYPE]); + } + if (isset($array[self::KEY_OUTPUT_SCHEMA])) { + $config->setOutputSchema($array[self::KEY_OUTPUT_SCHEMA]); + } + if (isset($array[self::KEY_OUTPUT_MEDIA_ORIENTATION])) { + $config->setOutputMediaOrientation(MediaOrientationEnum::from($array[self::KEY_OUTPUT_MEDIA_ORIENTATION])); + } + if (isset($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO])) { + $config->setOutputMediaAspectRatio($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO]); + } + if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) { + $config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]); + } + if (isset($array[self::KEY_CUSTOM_OPTIONS])) { + $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); + } + return $config; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php new file mode 100644 index 0000000000000..ee2775a018f12 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelMetadata.php @@ -0,0 +1,165 @@ +, + * supportedOptions: list + * } + * + * @extends AbstractDataTransferObject + */ +class ModelMetadata extends AbstractDataTransferObject +{ + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_SUPPORTED_CAPABILITIES = 'supportedCapabilities'; + public const KEY_SUPPORTED_OPTIONS = 'supportedOptions'; + /** + * @var string The model's unique identifier. + */ + protected string $id; + /** + * @var string The model's display name. + */ + protected string $name; + /** + * @var list The model's supported capabilities. + */ + protected array $supportedCapabilities; + /** + * @var list The model's supported configuration options. + */ + protected array $supportedOptions; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $id The model's unique identifier. + * @param string $name The model's display name. + * @param list $supportedCapabilities The model's supported capabilities. + * @param list $supportedOptions The model's supported configuration options. + * + * @throws InvalidArgumentException If arrays are not lists. + */ + public function __construct(string $id, string $name, array $supportedCapabilities, array $supportedOptions) + { + if (!array_is_list($supportedCapabilities)) { + throw new InvalidArgumentException('Supported capabilities must be a list array.'); + } + if (!array_is_list($supportedOptions)) { + throw new InvalidArgumentException('Supported options must be a list array.'); + } + $this->id = $id; + $this->name = $name; + $this->supportedCapabilities = $supportedCapabilities; + $this->supportedOptions = $supportedOptions; + } + /** + * Gets the model's unique identifier. + * + * @since 0.1.0 + * + * @return string The model ID. + */ + public function getId(): string + { + return $this->id; + } + /** + * Gets the model's display name. + * + * @since 0.1.0 + * + * @return string The model name. + */ + public function getName(): string + { + return $this->name; + } + /** + * Gets the model's supported capabilities. + * + * @since 0.1.0 + * + * @return list The supported capabilities. + */ + public function getSupportedCapabilities(): array + { + return $this->supportedCapabilities; + } + /** + * Gets the model's supported configuration options. + * + * @since 0.1.0 + * + * @return list The supported options. + */ + public function getSupportedOptions(): array + { + return $this->supportedOptions; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The model\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The model\'s display name.'], self::KEY_SUPPORTED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The model\'s supported capabilities.'], self::KEY_SUPPORTED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::getJsonSchema(), 'description' => 'The model\'s supported configuration options.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ModelMetadataArrayShape + */ + public function toArray(): array + { + return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_SUPPORTED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->supportedCapabilities), self::KEY_SUPPORTED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\SupportedOption $option): array => $option->toArray(), $this->supportedOptions)]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]); + return new self($array[self::KEY_ID], $array[self::KEY_NAME], array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_SUPPORTED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\SupportedOption => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::fromArray($optionData), $array[self::KEY_SUPPORTED_OPTIONS])); + } + /** + * Performs a deep clone of the model metadata. + * + * This method ensures that supported option objects are cloned to prevent + * modifications to the cloned metadata from affecting the original. + * + * @since 0.4.1 + */ + public function __clone() + { + $clonedOptions = []; + foreach ($this->supportedOptions as $option) { + $clonedOptions[] = clone $option; + } + $this->supportedOptions = $clonedOptions; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php new file mode 100644 index 0000000000000..0f2bb865ca55a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/ModelRequirements.php @@ -0,0 +1,315 @@ +, + * requiredOptions: list + * } + * + * @extends AbstractDataTransferObject + */ +class ModelRequirements extends AbstractDataTransferObject +{ + public const KEY_REQUIRED_CAPABILITIES = 'requiredCapabilities'; + public const KEY_REQUIRED_OPTIONS = 'requiredOptions'; + /** + * @var list The capabilities that the model must support. + */ + protected array $requiredCapabilities; + /** + * @var list The options that the model must support with specific values. + */ + protected array $requiredOptions; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param list $requiredCapabilities The capabilities that the model must support. + * @param list $requiredOptions The options that the model must support with specific values. + * + * @throws InvalidArgumentException If arrays are not lists. + */ + public function __construct(array $requiredCapabilities, array $requiredOptions) + { + if (!array_is_list($requiredCapabilities)) { + throw new InvalidArgumentException('Required capabilities must be a list array.'); + } + if (!array_is_list($requiredOptions)) { + throw new InvalidArgumentException('Required options must be a list array.'); + } + $this->requiredCapabilities = $requiredCapabilities; + $this->requiredOptions = $requiredOptions; + } + /** + * Gets the capabilities that the model must support. + * + * @since 0.1.0 + * + * @return list The required capabilities. + */ + public function getRequiredCapabilities(): array + { + return $this->requiredCapabilities; + } + /** + * Gets the options that the model must support with specific values. + * + * @since 0.1.0 + * + * @return list The required options. + */ + public function getRequiredOptions(): array + { + return $this->requiredOptions; + } + /** + * Checks whether the given model metadata meets these requirements. + * + * @since 0.2.0 + * + * @param ModelMetadata $metadata The model metadata to check against. + * @return bool True if the model meets all requirements, false otherwise. + */ + public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): bool + { + // Create lookup maps for better performance (instead of nested foreach loops) + $capabilitiesMap = []; + foreach ($metadata->getSupportedCapabilities() as $capability) { + $capabilitiesMap[$capability->value] = $capability; + } + $optionsMap = []; + foreach ($metadata->getSupportedOptions() as $option) { + $optionsMap[$option->getName()->value] = $option; + } + // Check if all required capabilities are supported using map lookup + foreach ($this->requiredCapabilities as $requiredCapability) { + if (!isset($capabilitiesMap[$requiredCapability->value])) { + return \false; + } + } + // Check if all required options are supported with the specified values + foreach ($this->requiredOptions as $requiredOption) { + // Use map lookup instead of linear search + if (!isset($optionsMap[$requiredOption->getName()->value])) { + return \false; + } + $supportedOption = $optionsMap[$requiredOption->getName()->value]; + // Check if the required value is supported by this option + if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { + return \false; + } + } + return \true; + } + /** + * Creates ModelRequirements from prompt data and model configuration. + * + * @since 0.2.0 + * + * @param CapabilityEnum $capability The capability the model must support. + * @param list $messages The messages in the conversation. + * @param ModelConfig $modelConfig The model configuration. + * @return self The created requirements. + */ + public static function fromPromptData(CapabilityEnum $capability, array $messages, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self + { + // Start with base capability + $capabilities = [$capability]; + $inputModalities = []; + // Check if we have chat history (multiple messages) + if (count($messages) > 1) { + $capabilities[] = CapabilityEnum::chatHistory(); + } + // Analyze all messages to determine required input modalities + $hasFunctionMessageParts = \false; + foreach ($messages as $message) { + foreach ($message->getParts() as $part) { + // Check for text input + if ($part->getType()->isText()) { + $inputModalities[] = ModalityEnum::text(); + } + // Check for file inputs + if ($part->getType()->isFile()) { + $file = $part->getFile(); + if ($file !== null) { + if ($file->isImage()) { + $inputModalities[] = ModalityEnum::image(); + } elseif ($file->isAudio()) { + $inputModalities[] = ModalityEnum::audio(); + } elseif ($file->isVideo()) { + $inputModalities[] = ModalityEnum::video(); + } elseif ($file->isDocument() || $file->isText()) { + $inputModalities[] = ModalityEnum::document(); + } + } + } + // Check for function calls/responses (these might require special capabilities) + if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) { + $hasFunctionMessageParts = \true; + } + } + } + // Convert ModelConfig to RequiredOptions + $requiredOptions = self::toRequiredOptions($modelConfig); + // Add additional options based on message analysis + if ($hasFunctionMessageParts) { + $requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true)); + } + // Add input modalities if we have any inputs + if (!empty($inputModalities)) { + // Remove duplicates + $inputModalities = array_unique($inputModalities, \SORT_REGULAR); + $requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities))); + } + // Step 6: Return new ModelRequirements + return new self($capabilities, $requiredOptions); + } + /** + * Converts ModelConfig to an array of RequiredOptions. + * + * @since 0.2.0 + * + * @param ModelConfig $modelConfig The model configuration. + * @return list The required options. + */ + private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): array + { + $requiredOptions = []; + // Map properties that have corresponding OptionEnum values + if ($modelConfig->getOutputModalities() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputModalities(), $modelConfig->getOutputModalities()); + } + if ($modelConfig->getSystemInstruction() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::systemInstruction(), $modelConfig->getSystemInstruction()); + } + if ($modelConfig->getCandidateCount() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::candidateCount(), $modelConfig->getCandidateCount()); + } + if ($modelConfig->getMaxTokens() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::maxTokens(), $modelConfig->getMaxTokens()); + } + if ($modelConfig->getTemperature() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::temperature(), $modelConfig->getTemperature()); + } + if ($modelConfig->getTopP() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topP(), $modelConfig->getTopP()); + } + if ($modelConfig->getTopK() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topK(), $modelConfig->getTopK()); + } + if ($modelConfig->getOutputMimeType() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMimeType(), $modelConfig->getOutputMimeType()); + } + if ($modelConfig->getOutputSchema() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputSchema(), $modelConfig->getOutputSchema()); + } + // Handle properties without OptionEnum values as custom options + if ($modelConfig->getStopSequences() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::stopSequences(), $modelConfig->getStopSequences()); + } + if ($modelConfig->getPresencePenalty() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::presencePenalty(), $modelConfig->getPresencePenalty()); + } + if ($modelConfig->getFrequencyPenalty() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::frequencyPenalty(), $modelConfig->getFrequencyPenalty()); + } + if ($modelConfig->getLogprobs() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::logprobs(), $modelConfig->getLogprobs()); + } + if ($modelConfig->getTopLogprobs() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topLogprobs(), $modelConfig->getTopLogprobs()); + } + if ($modelConfig->getFunctionDeclarations() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true); + } + if ($modelConfig->getWebSearch() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::webSearch(), \true); + } + if ($modelConfig->getOutputFileType() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputFileType(), $modelConfig->getOutputFileType()); + } + if ($modelConfig->getOutputMediaOrientation() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaOrientation(), $modelConfig->getOutputMediaOrientation()); + } + if ($modelConfig->getOutputMediaAspectRatio() !== null) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio()); + } + // Add custom options as individual RequiredOptions + foreach ($modelConfig->getCustomOptions() as $key => $value) { + $requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]); + } + return $requiredOptions; + } + /** + * Includes a RequiredOption in the array, ensuring no duplicates based on option name. + * + * @since 0.2.0 + * + * @param list $requiredOptions The existing required options. + * @param RequiredOption $newOption The new option to include. + * @return list The updated required options array. + */ + private static function includeInRequiredOptions(array $requiredOptions, \WordPress\AiClient\Providers\Models\DTO\RequiredOption $newOption): array + { + // Check if we already have this option name + foreach ($requiredOptions as $index => $existingOption) { + if ($existingOption->getName()->equals($newOption->getName())) { + // Replace existing option with new one + $requiredOptions[$index] = $newOption; + return $requiredOptions; + } + } + // Option not found, add it + $requiredOptions[] = $newOption; + return $requiredOptions; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_REQUIRED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The capabilities that the model must support.'], self::KEY_REQUIRED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::getJsonSchema(), 'description' => 'The options that the model must support with specific values.']], 'required' => [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return ModelRequirementsArrayShape + */ + public function toArray(): array + { + return [self::KEY_REQUIRED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->requiredCapabilities), self::KEY_REQUIRED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\RequiredOption $option): array => $option->toArray(), $this->requiredOptions)]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]); + return new self(array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_REQUIRED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\RequiredOption => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::fromArray($optionData), $array[self::KEY_REQUIRED_OPTIONS])); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php new file mode 100644 index 0000000000000..e459a74e9cfb3 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/RequiredOption.php @@ -0,0 +1,100 @@ + + */ +class RequiredOption extends AbstractDataTransferObject +{ + public const KEY_NAME = 'name'; + public const KEY_VALUE = 'value'; + /** + * @var OptionEnum The option name. + */ + protected OptionEnum $name; + /** + * @var mixed The value that the model must support for this option. + */ + protected $value; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param OptionEnum $name The option name. + * @param mixed $value The value that the model must support for this option. + */ + public function __construct(OptionEnum $name, $value) + { + $this->name = $name; + $this->value = $value; + } + /** + * Gets the option name. + * + * @since 0.1.0 + * + * @return OptionEnum The option name. + */ + public function getName(): OptionEnum + { + return $this->name; + } + /** + * Gets the value that the model must support for this option. + * + * @since 0.1.0 + * + * @return mixed The value that the model must support. + */ + public function getValue() + { + return $this->value; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_VALUE => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']], 'description' => 'The value that the model must support for this option.']], 'required' => [self::KEY_NAME, self::KEY_VALUE]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return RequiredOptionArrayShape + */ + public function toArray(): array + { + return [self::KEY_NAME => $this->name->value, self::KEY_VALUE => $this->value]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_VALUE]); + return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_VALUE]); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php new file mode 100644 index 0000000000000..9fd337eb6152a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/DTO/SupportedOption.php @@ -0,0 +1,142 @@ + + * } + * + * @extends AbstractDataTransferObject + */ +class SupportedOption extends AbstractDataTransferObject +{ + public const KEY_NAME = 'name'; + public const KEY_SUPPORTED_VALUES = 'supportedValues'; + /** + * @var OptionEnum The option name. + */ + protected OptionEnum $name; + /** + * @var list|null The supported values for this option. + */ + protected ?array $supportedValues; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param OptionEnum $name The option name. + * @param list|null $supportedValues The supported values for this option, or null if any value is supported. + * + * @throws InvalidArgumentException If supportedValues is not null and not a list. + */ + public function __construct(OptionEnum $name, ?array $supportedValues = null) + { + if ($supportedValues !== null && !array_is_list($supportedValues)) { + throw new InvalidArgumentException('Supported values must be a list array.'); + } + $this->name = $name; + $this->supportedValues = $supportedValues; + } + /** + * Gets the option name. + * + * @since 0.1.0 + * + * @return OptionEnum The option name. + */ + public function getName(): OptionEnum + { + return $this->name; + } + /** + * Checks if a value is supported for this option. + * + * @since 0.1.0 + * + * @param mixed $value The value to check. + * @return bool True if the value is supported, false otherwise. + */ + public function isSupportedValue($value): bool + { + // If supportedValues is null, any value is supported + if ($this->supportedValues === null) { + return \true; + } + // If the value is an array, consider it a set (i.e. order doesn't matter). + if (is_array($value)) { + sort($value); + foreach ($this->supportedValues as $supportedValue) { + if (!is_array($supportedValue)) { + continue; + } + sort($supportedValue); + if ($value === $supportedValue) { + return \true; + } + } + return \false; + } + return in_array($value, $this->supportedValues, \true); + } + /** + * Gets the supported values for this option. + * + * @since 0.1.0 + * + * @return list|null The supported values, or null if any value is supported. + */ + public function getSupportedValues(): ?array + { + return $this->supportedValues; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_SUPPORTED_VALUES => ['type' => 'array', 'items' => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']]], 'description' => 'The supported values for this option.']], 'required' => [self::KEY_NAME]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return SupportedOptionArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_NAME => $this->name->value]; + if ($this->supportedValues !== null) { + /** @var list $supportedValues */ + $supportedValues = $this->supportedValues; + $data[self::KEY_SUPPORTED_VALUES] = $supportedValues; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_NAME]); + return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_SUPPORTED_VALUES] ?? null); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php new file mode 100644 index 0000000000000..b0bcf5abec89a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/Enums/CapabilityEnum.php @@ -0,0 +1,63 @@ + The enum constants. + */ + protected static function determineClassEnumerations(string $className): array + { + // Start with the constants defined in this class using parent method + $constants = parent::determineClassEnumerations($className); + // Use reflection to get all constants from ModelConfig + $modelConfigReflection = new ReflectionClass(ModelConfig::class); + $modelConfigConstants = $modelConfigReflection->getConstants(); + // Add ModelConfig constants that start with KEY_ + foreach ($modelConfigConstants as $constantName => $constantValue) { + if (str_starts_with($constantName, 'KEY_')) { + // Remove KEY_ prefix to get the enum constant name + $enumConstantName = substr($constantName, 4); + // The value is the snake_case version stored in ModelConfig + // ModelConfig already stores these as snake_case strings + if (is_string($constantValue)) { + $constants[$enumConstantName] = $constantValue; + } + } + } + return $constants; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php new file mode 100644 index 0000000000000..34fb5ad91f6b8 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the image generation prompt. + * @return GenerativeAiResult Result containing generated images. + */ + public function generateImageResult(array $prompt): GenerativeAiResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php new file mode 100644 index 0000000000000..52470600117a3 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/ImageGeneration/Contracts/ImageGenerationOperationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the image generation prompt. + * @return GenerativeAiOperation The initiated image generation operation. + */ + public function generateImageOperation(array $prompt): GenerativeAiOperation; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php new file mode 100644 index 0000000000000..6fbf222f90e0c --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the speech generation prompt. + * @return GenerativeAiResult Result containing generated speech audio. + */ + public function generateSpeechResult(array $prompt): GenerativeAiResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php new file mode 100644 index 0000000000000..55305e7a6e6d3 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/SpeechGeneration/Contracts/SpeechGenerationOperationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the speech generation prompt. + * @return GenerativeAiOperation The initiated speech generation operation. + */ + public function generateSpeechOperation(array $prompt): GenerativeAiOperation; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php new file mode 100644 index 0000000000000..b455206e86f1a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the text generation prompt. + * @return GenerativeAiResult Result containing generated text. + */ + public function generateTextResult(array $prompt): GenerativeAiResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php new file mode 100644 index 0000000000000..a4ae0de91863f --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextGeneration/Contracts/TextGenerationOperationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the text generation prompt. + * @return GenerativeAiOperation The initiated text generation operation. + */ + public function generateTextOperation(array $prompt): GenerativeAiOperation; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php new file mode 100644 index 0000000000000..e97c580803642 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the text to convert to speech. + * @return GenerativeAiResult Result containing generated speech audio. + */ + public function convertTextToSpeechResult(array $prompt): GenerativeAiResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php new file mode 100644 index 0000000000000..e048bf2a780aa --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/TextToSpeechConversion/Contracts/TextToSpeechConversionOperationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the text to convert to speech. + * @return GenerativeAiOperation The initiated text-to-speech conversion operation. + */ + public function convertTextToSpeechOperation(array $prompt): GenerativeAiOperation; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php new file mode 100644 index 0000000000000..c0747093efadb --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -0,0 +1,298 @@ +, + * usage?: UsageData + * } + */ +abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface +{ + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function generateImageResult(array $prompt): GenerativeAiResult + { + $httpTransporter = $this->getHttpTransporter(); + $params = $this->prepareGenerateImageParams($prompt); + $request = $this->createRequest(HttpMethodEnum::POST(), 'images/generations', ['Content-Type' => 'application/json'], $params); + // Add authentication credentials to the request. + $request = $this->getRequestAuthentication()->authenticateRequest($request); + // Send and process the request. + $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); + return $this->parseResponseToGenerativeAiResult($response, isset($params['output_format']) && is_string($params['output_format']) ? "image/{$params['output_format']}" : 'image/png'); + } + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since 0.1.0 + * + * @param list $prompt The prompt to generate an image for. Either a single message or a list of messages + * from a chat. However as of today, OpenAI compatible image generation endpoints only + * support a single user message. + * @return ImageGenerationParams The parameters for the API request. + */ + protected function prepareGenerateImageParams(array $prompt): array + { + $config = $this->getConfig(); + $params = ['model' => $this->metadata()->getId(), 'prompt' => $this->preparePromptParam($prompt)]; + $candidateCount = $config->getCandidateCount(); + if ($candidateCount !== null) { + $params['n'] = $candidateCount; + } + $outputFileType = $config->getOutputFileType(); + if ($outputFileType !== null) { + $params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json'; + } else { + // The 'response_format' parameter is required, so we default to 'b64_json' if not set. + $params['response_format'] = 'b64_json'; + } + $outputMimeType = $config->getOutputMimeType(); + if ($outputMimeType !== null) { + $params['output_format'] = preg_replace('/^image\//', '', $outputMimeType); + } + $outputMediaOrientation = $config->getOutputMediaOrientation(); + $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); + if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { + $params['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio); + } + /* + * Any custom options are added to the parameters as well. + * This allows developers to pass other options that may be more niche or not yet supported by the SDK. + */ + $customOptions = $config->getCustomOptions(); + foreach ($customOptions as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key)); + } + $params[$key] = $value; + } + /** @var ImageGenerationParams $params */ + return $params; + } + /** + * Prepares the prompt parameter for the API request. + * + * @since 0.1.0 + * + * @param list $messages The messages to prepare. However as of today, OpenAI compatible image generation + * endpoints only support a single user message. + * @return string The prepared prompt parameter. + */ + protected function preparePromptParam(array $messages): string + { + if (count($messages) !== 1) { + throw new InvalidArgumentException('The API requires a single user message as prompt.'); + } + $message = $messages[0]; + if (!$message->getRole()->isUser()) { + throw new InvalidArgumentException('The API requires a user message as prompt.'); + } + $text = null; + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + break; + } + } + if ($text === null) { + throw new InvalidArgumentException('The API requires a single text message part as prompt.'); + } + return $text; + } + /** + * Prepares the size parameter for the API request. + * + * @since 0.1.0 + * + * @param MediaOrientationEnum|null $orientation The desired media orientation. + * @param string|null $aspectRatio The desired media aspect ratio. + * @return string The prepared size parameter. + */ + protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + // Use aspect ratio if set, as it is more specific. + if ($aspectRatio !== null) { + switch ($aspectRatio) { + case '1:1': + return '1024x1024'; + case '3:2': + return '1536x1024'; + case '7:4': + return '1792x1024'; + case '2:3': + return '1024x1536'; + case '4:7': + return '1024x1792'; + default: + throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not supported.'); + } + } + // This should always have a value, as the method is only called if at least one or the other is set. + if ($orientation !== null) { + if ($orientation->isLandscape()) { + return '1536x1024'; + } + if ($orientation->isPortrait()) { + return '1024x1536'; + } + } + return '1024x1024'; + } + /** + * Creates a request object for the provider's API. + * + * Implementations should use $this->getRequestOptions() to attach any + * configured request options to the Request. + * + * @since 0.1.0 + * + * @param HttpMethodEnum $method The HTTP method. + * @param string $path The API endpoint path, relative to the base URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. + * @return Request The request object. + */ + abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request; + /** + * Throws an exception if the response is not successful. + * + * @since 0.1.0 + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ + ResponseUtil::throwIfNotSuccessful($response); + } + /** + * Parses the response from the API endpoint to a generative AI result. + * + * @since 0.1.0 + * + * @param Response $response The response from the API endpoint. + * @param string $expectedMimeType The expected MIME type the response is in. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult(Response $response, string $expectedMimeType = 'image/png'): GenerativeAiResult + { + /** @var ResponseData $responseData */ + $responseData = $response->getData(); + if (!isset($responseData['data']) || !$responseData['data']) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); + } + if (!is_array($responseData['data'])) { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'data', 'The value must be an array.'); + } + $candidates = []; + foreach ($responseData['data'] as $index => $choiceData) { + if (!is_array($choiceData) || array_is_list($choiceData)) { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", 'The value must be an associative array.'); + } + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); + } + $id = $this->getResultId($responseData); + if (isset($responseData['usage']) && is_array($responseData['usage'])) { + $usage = $responseData['usage']; + $tokenUsage = new TokenUsage($usage['input_tokens'] ?? 0, $usage['output_tokens'] ?? 0, $usage['total_tokens'] ?? 0); + } else { + $tokenUsage = new TokenUsage(0, 0, 0); + } + // Use any other data from the response as provider-specific response metadata. + $providerMetadata = $responseData; + unset($providerMetadata['id'], $providerMetadata['data'], $providerMetadata['usage']); + return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $providerMetadata); + } + /** + * Parses a single choice from the API response into a Candidate object. + * + * @since 0.1.0 + * + * @param ChoiceData $choiceData The choice data from the API response. + * @param int $index The index of the choice in the choices array. + * @param string $expectedMimeType The expected MIME type the response is in. + * @return Candidate The parsed candidate. + * @throws RuntimeException If the choice data is invalid. + */ + protected function parseResponseChoiceToCandidate(array $choiceData, int $index, string $expectedMimeType = 'image/png'): Candidate + { + if (isset($choiceData['url']) && is_string($choiceData['url'])) { + $imageFile = new File($choiceData['url'], $expectedMimeType); + } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) { + $imageFile = new File($choiceData['b64_json'], $expectedMimeType); + } else { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must contain either a url or b64_json key with a string value.'); + } + $parts = [new MessagePart($imageFile)]; + $message = new Message(MessageRoleEnum::model(), $parts); + return new Candidate($message, FinishReasonEnum::stop()); + } + /** + * Extracts the result ID from the API response data. + * + * @since 0.4.0 + * + * @param array $responseData The response data from the API. + * @return string The result ID. + */ + protected function getResultId(array $responseData): string + { + return isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php new file mode 100644 index 0000000000000..cc5e0e9ab1df9 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -0,0 +1,80 @@ +getHttpTransporter(); + $request = $this->createRequest(HttpMethodEnum::GET(), 'models'); + $request = $this->getRequestAuthentication()->authenticateRequest($request); + $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); + $modelsMetadataList = $this->parseResponseToModelMetadataList($response); + $modelMetadataMap = []; + foreach ($modelsMetadataList as $modelMetadata) { + $modelMetadataMap[$modelMetadata->getId()] = $modelMetadata; + } + return $modelMetadataMap; + } + /** + * Creates a request object for the provider's API. + * + * @since 0.1.0 + * + * @param HttpMethodEnum $method The HTTP method. + * @param string $path The API endpoint path, relative to the base URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. + * @return Request The request object. + */ + abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request; + /** + * Throws an exception if the response is not successful. + * + * @since 0.1.0 + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ + ResponseUtil::throwIfNotSuccessful($response); + } + /** + * Parses the response from the API endpoint to list models into a list of model metadata objects. + * + * @since 0.1.0 + * + * @param Response $response The response from the API endpoint to list models. + * @return list List of model metadata objects. + */ + abstract protected function parseResponseToModelMetadataList(Response $response): array; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php new file mode 100644 index 0000000000000..adbbd5dad9f49 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -0,0 +1,557 @@ + + * } + * } + * @phpstan-type MessageData array{ + * role?: string, + * reasoning_content?: string, + * content?: string, + * tool_calls?: list + * } + * @phpstan-type ChoiceData array{ + * message?: MessageData, + * finish_reason?: string + * } + * @phpstan-type UsageData array{ + * prompt_tokens?: int, + * completion_tokens?: int, + * total_tokens?: int + * } + * @phpstan-type ResponseData array{ + * id?: string, + * choices?: list, + * usage?: UsageData + * } + */ +abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface +{ + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + final public function generateTextResult(array $prompt): GenerativeAiResult + { + $httpTransporter = $this->getHttpTransporter(); + $params = $this->prepareGenerateTextParams($prompt); + $request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', ['Content-Type' => 'application/json'], $params); + // Add authentication credentials to the request. + $request = $this->getRequestAuthentication()->authenticateRequest($request); + // Send and process the request. + $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); + return $this->parseResponseToGenerativeAiResult($response); + } + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since 0.1.0 + * + * @param list $prompt The prompt to generate text for. Either a single message or a list of messages + * from a chat. + * @return array The parameters for the API request. + */ + protected function prepareGenerateTextParams(array $prompt): array + { + $config = $this->getConfig(); + $params = ['model' => $this->metadata()->getId(), 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction())]; + $outputModalities = $config->getOutputModalities(); + if (is_array($outputModalities)) { + $this->validateOutputModalities($outputModalities); + if (count($outputModalities) > 1) { + $params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities); + } + } + $candidateCount = $config->getCandidateCount(); + if ($candidateCount !== null) { + $params['n'] = $candidateCount; + } + $maxTokens = $config->getMaxTokens(); + if ($maxTokens !== null) { + $params['max_tokens'] = $maxTokens; + } + $temperature = $config->getTemperature(); + if ($temperature !== null) { + $params['temperature'] = $temperature; + } + $topP = $config->getTopP(); + if ($topP !== null) { + $params['top_p'] = $topP; + } + $stopSequences = $config->getStopSequences(); + if (is_array($stopSequences)) { + $params['stop'] = $stopSequences; + } + $presencePenalty = $config->getPresencePenalty(); + if ($presencePenalty !== null) { + $params['presence_penalty'] = $presencePenalty; + } + $frequencyPenalty = $config->getFrequencyPenalty(); + if ($frequencyPenalty !== null) { + $params['frequency_penalty'] = $frequencyPenalty; + } + $logprobs = $config->getLogprobs(); + if ($logprobs !== null) { + $params['logprobs'] = $logprobs; + } + $topLogprobs = $config->getTopLogprobs(); + if ($topLogprobs !== null) { + $params['top_logprobs'] = $topLogprobs; + } + $functionDeclarations = $config->getFunctionDeclarations(); + if (is_array($functionDeclarations)) { + $params['tools'] = $this->prepareToolsParam($functionDeclarations); + } + $outputMimeType = $config->getOutputMimeType(); + if ('application/json' === $outputMimeType) { + $outputSchema = $config->getOutputSchema(); + $params['response_format'] = $this->prepareResponseFormatParam($outputSchema); + } + /* + * Any custom options are added to the parameters as well. + * This allows developers to pass other options that may be more niche or not yet supported by the SDK. + */ + $customOptions = $config->getCustomOptions(); + foreach ($customOptions as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key)); + } + $params[$key] = $value; + } + return $params; + } + /** + * Prepares the messages parameter for the API request. + * + * @since 0.1.0 + * + * @param list $messages The messages to prepare. + * @param string|null $systemInstruction An optional system instruction to prepend to the messages. + * @return list> The prepared messages parameter. + */ + protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array + { + $messagesParam = array_map(function (Message $message): array { + // Special case: Function response. + $messageParts = $message->getParts(); + if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { + $functionResponse = $messageParts[0]->getFunctionResponse(); + if (!$functionResponse) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException('The function response typed message part must contain a function response.'); + } + return ['role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId()]; + } + $messageData = ['role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map([$this, 'getMessagePartContentData'], $messageParts)))]; + // Only include tool_calls if there are any (OpenAI rejects empty arrays). + $toolCalls = array_values(array_filter(array_map([$this, 'getMessagePartToolCallData'], $messageParts))); + if (!empty($toolCalls)) { + $messageData['tool_calls'] = $toolCalls; + } + return $messageData; + }, $messages); + if ($systemInstruction) { + array_unshift($messagesParam, [ + /* + * TODO: Replace this with 'developer' in the future. + * See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages + */ + 'role' => 'system', + 'content' => [['type' => 'text', 'text' => $systemInstruction]], + ]); + } + return $messagesParam; + } + /** + * Returns the OpenAI API specific role string for the given message role. + * + * @since 0.1.0 + * + * @param MessageRoleEnum $role The message role. + * @return string The role for the API request. + */ + protected function getMessageRoleString(MessageRoleEnum $role): string + { + if ($role === MessageRoleEnum::model()) { + return 'assistant'; + } + return 'user'; + } + /** + * Returns the OpenAI API specific content data for a message part. + * + * @since 0.1.0 + * + * @param MessagePart $part The message part to get the data for. + * @return ?array The data for the message content part, or null if not applicable. + * @throws InvalidArgumentException If the message part type or data is unsupported. + */ + protected function getMessagePartContentData(MessagePart $part): ?array + { + $type = $part->getType(); + if ($type->isText()) { + /* + * The OpenAI Chat Completions API spec does not support annotating thought parts as input, + * so we instead skip them. + */ + if ($part->getChannel()->isThought()) { + return null; + } + return ['type' => 'text', 'text' => $part->getText()]; + } + if ($type->isFile()) { + $file = $part->getFile(); + if (!$file) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException('The file typed message part must contain a file.'); + } + if ($file->isRemote()) { + if ($file->isImage()) { + return ['type' => 'image_url', 'image_url' => ['url' => $file->getUrl()]]; + } + throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for remote file message part.', $file->getMimeType())); + } + // Else, it is an inline file. + if ($file->isImage()) { + return ['type' => 'image_url', 'image_url' => ['url' => $file->getDataUri()]]; + } + if ($file->isAudio()) { + return ['type' => 'input_audio', 'input_audio' => ['data' => $file->getBase64Data(), 'format' => $file->getMimeTypeObject()->toExtension()]]; + } + throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for inline file message part.', $file->getMimeType())); + } + if ($type->isFunctionCall()) { + // Skip, as this is separately included. See `getMessagePartToolCallData()`. + return null; + } + if ($type->isFunctionResponse()) { + // Special case: Function response. + throw new InvalidArgumentException('The API only allows a single function response, as the only content of the message.'); + } + throw new InvalidArgumentException(sprintf('Unsupported message part type "%s".', $type)); + } + /** + * Returns the OpenAI API specific tool calls data for a message part. + * + * @since 0.1.0 + * + * @param MessagePart $part The message part to get the data for. + * @return ?array The data for the message tool call part, or null if not applicable. + * @throws InvalidArgumentException If the message part type or data is unsupported. + */ + protected function getMessagePartToolCallData(MessagePart $part): ?array + { + $type = $part->getType(); + if ($type->isFunctionCall()) { + $functionCall = $part->getFunctionCall(); + if (!$functionCall) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException('The function call typed message part must contain a function call.'); + } + $args = $functionCall->getArgs(); + /* + * Ensure null or empty arrays become empty objects for JSON encoding. + * While in theory the JSON schema could also dictate a type of + * 'array', in practice function arguments are typically of type + * 'object'. More importantly, the OpenAI API specification seems + * to expect that, and does not support passing arrays as the root + * value. The null check handles the case where FunctionCall normalizes + * empty arrays to null. + */ + if ($args === null || is_array($args) && count($args) === 0) { + $args = new \stdClass(); + } + return ['type' => 'function', 'id' => $functionCall->getId(), 'function' => ['name' => $functionCall->getName(), 'arguments' => json_encode($args)]]; + } + // All other types are handled in `getMessagePartContentData()`. + return null; + } + /** + * Validates that the given output modalities to ensure that at least one output modality is text. + * + * @since 0.1.0 + * + * @param array $outputModalities The output modalities to validate. + * @throws InvalidArgumentException If no text output modality is present. + */ + protected function validateOutputModalities(array $outputModalities): void + { + // If no output modalities are set, it's fine, as we can assume text. + if (count($outputModalities) === 0) { + return; + } + foreach ($outputModalities as $modality) { + if ($modality->isText()) { + return; + } + } + throw new InvalidArgumentException('A text output modality must be present when generating text.'); + } + /** + * Prepares the output modalities parameter for the API request. + * + * @since 0.1.0 + * + * @param array $modalities The modalities to prepare. + * @return list The prepared modalities parameter. + */ + protected function prepareOutputModalitiesParam(array $modalities): array + { + $prepared = []; + foreach ($modalities as $modality) { + if ($modality->isText()) { + $prepared[] = 'text'; + } elseif ($modality->isImage()) { + $prepared[] = 'image'; + } elseif ($modality->isAudio()) { + $prepared[] = 'audio'; + } else { + throw new InvalidArgumentException(sprintf('Unsupported output modality "%s".', $modality)); + } + } + return $prepared; + } + /** + * Prepares the tools parameter for the API request. + * + * @since 0.1.0 + * + * @param list $functionDeclarations The function declarations. + * @return list> The prepared tools parameter. + */ + protected function prepareToolsParam(array $functionDeclarations): array + { + $tools = []; + foreach ($functionDeclarations as $functionDeclaration) { + $tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()]; + } + return $tools; + } + /** + * Prepares the response format parameter for the API request. + * + * This is only called if the output MIME type is `application/json`. + * + * @since 0.1.0 + * + * @param array|null $outputSchema The output schema. + * @return array The prepared response format parameter. + */ + protected function prepareResponseFormatParam(?array $outputSchema): array + { + if (is_array($outputSchema)) { + return ['type' => 'json_schema', 'json_schema' => $outputSchema]; + } + return ['type' => 'json_object']; + } + /** + * Creates a request object for the provider's API. + * + * Implementations should use $this->getRequestOptions() to attach any + * configured request options to the Request. + * + * @since 0.1.0 + * + * @param HttpMethodEnum $method The HTTP method. + * @param string $path The API endpoint path, relative to the base URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. + * @return Request The request object. + */ + abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request; + /** + * Throws an exception if the response is not successful. + * + * @since 0.1.0 + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ + ResponseUtil::throwIfNotSuccessful($response); + } + /** + * Parses the response from the API endpoint to a generative AI result. + * + * @since 0.1.0 + * + * @param Response $response The response from the API endpoint. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult + { + /** @var ResponseData $responseData */ + $responseData = $response->getData(); + if (!isset($responseData['choices']) || !$responseData['choices']) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices'); + } + if (!is_array($responseData['choices'])) { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'choices', 'The value must be an array.'); + } + $candidates = []; + foreach ($responseData['choices'] as $index => $choiceData) { + if (!is_array($choiceData) || array_is_list($choiceData)) { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must be an associative array.'); + } + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index); + } + $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + if (isset($responseData['usage']) && is_array($responseData['usage'])) { + $usage = $responseData['usage']; + $tokenUsage = new TokenUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0); + } else { + $tokenUsage = new TokenUsage(0, 0, 0); + } + // Use any other data from the response as provider-specific response metadata. + $additionalData = $responseData; + unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']); + return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $additionalData); + } + /** + * Parses a single choice from the API response into a Candidate object. + * + * @since 0.1.0 + * + * @param ChoiceData $choiceData The choice data from the API response. + * @param int $index The index of the choice in the choices array. + * @return Candidate The parsed candidate. + * @throws RuntimeException If the choice data is invalid. + */ + protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate + { + if (!isset($choiceData['message']) || !is_array($choiceData['message']) || array_is_list($choiceData['message'])) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].message"); + } + if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason"); + } + $messageData = $choiceData['message']; + $message = $this->parseResponseChoiceMessage($messageData, $index); + switch ($choiceData['finish_reason']) { + case 'stop': + $finishReason = FinishReasonEnum::stop(); + break; + case 'length': + $finishReason = FinishReasonEnum::length(); + break; + case 'content_filter': + $finishReason = FinishReasonEnum::contentFilter(); + break; + case 'tool_calls': + $finishReason = FinishReasonEnum::toolCalls(); + break; + default: + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason'])); + } + return new Candidate($message, $finishReason); + } + /** + * Parses the message from a choice in the API response. + * + * @since 0.1.0 + * + * @param MessageData $messageData The message data from the API response. + * @param int $index The index of the choice in the choices array. + * @return Message The parsed message. + */ + protected function parseResponseChoiceMessage(array $messageData, int $index): Message + { + $role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model(); + $parts = $this->parseResponseChoiceMessageParts($messageData, $index); + return new Message($role, $parts); + } + /** + * Parses the message parts from a choice in the API response. + * + * @since 0.1.0 + * + * @param MessageData $messageData The message data from the API response. + * @param int $index The index of the choice in the choices array. + * @return MessagePart[] The parsed message parts. + */ + protected function parseResponseChoiceMessageParts(array $messageData, int $index): array + { + $parts = []; + if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) { + $parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought()); + } + if (isset($messageData['content']) && is_string($messageData['content'])) { + $parts[] = new MessagePart($messageData['content']); + } + if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) { + foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) { + $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); + if (!$toolCallPart) { + throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.'); + } + $parts[] = $toolCallPart; + } + } + return $parts; + } + /** + * Parses a tool call part from the API response. + * + * @since 0.1.0 + * + * @param ToolCallData $toolCallData The tool call data from the API response. + * @return MessagePart|null The parsed message part for the tool call, or null if not applicable. + */ + protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart + { + /* + * For now, only function calls are supported. + * + * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set. + */ + if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) { + return null; + } + $functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], \true) : $toolCallData['function']['arguments']; + $functionCall = new FunctionCall(isset($toolCallData['id']) && is_string($toolCallData['id']) ? $toolCallData['id'] : null, isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? $toolCallData['function']['name'] : null, $functionArguments); + return new MessagePart($functionCall); + } +} diff --git a/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php b/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php new file mode 100644 index 0000000000000..107e303af33f7 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php @@ -0,0 +1,520 @@ +> Mapping of provider IDs to class names. + */ + private array $registeredIdsToClassNames = []; + /** + * @var array, string> Mapping of provider class names to IDs. + */ + private array $registeredClassNamesToIds = []; + /** + * @var array, RequestAuthenticationInterface> Mapping of provider class names to + * authentication instances. + */ + private array $providerAuthenticationInstances = []; + /** + * Registers a provider class with the registry. + * + * @since 0.1.0 + * + * @param class-string $className The fully qualified provider class name implementing the + * ProviderInterface + * @throws InvalidArgumentException If the class doesn't exist or implement the required interface. + */ + public function registerProvider(string $className): void + { + if (!class_exists($className)) { + throw new InvalidArgumentException(sprintf('Provider class does not exist: %s', $className)); + } + // Validate that class implements ProviderInterface + if (!is_subclass_of($className, ProviderInterface::class)) { + throw new InvalidArgumentException(sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className)); + } + $metadata = $className::metadata(); + if (!$metadata instanceof ProviderMetadata) { + throw new InvalidArgumentException(sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className)); + } + // If there is already a HTTP transporter instance set, hook it up to the provider as needed. + try { + $httpTransporter = $this->getHttpTransporter(); + } catch (RuntimeException $e) { + /* + * If this fails, it's okay. There is no defined sequence between setting the HTTP transporter in the + * registry and registering providers in it, so it might be that the transporter is set later. It will be + * hooked up then. + * But for now we can ignore this exception and attempt to set the default HTTP transporter, if possible. + */ + try { + $this->setHttpTransporter(HttpTransporterFactory::createTransporter()); + $httpTransporter = $this->getHttpTransporter(); + } catch (DiscoveryNotFoundException $e) { + /* + * If no HTTP client implementation can be discovered yet, we can ignore this for now. + * It might be set later, so it's not a hard error at this point. + * We'll try again the next time a provider is registered, or maybe by that time an explicit + * HTTP transporter will have been set. + */ + } + } + if (isset($httpTransporter)) { + $this->setHttpTransporterForProvider($className, $httpTransporter); + } + // Hook up the request authentication instance, using a default if not set. + if (!isset($this->providerAuthenticationInstances[$className])) { + $defaultProviderAuthentication = $this->createDefaultProviderRequestAuthentication($className); + if ($defaultProviderAuthentication !== null) { + $this->providerAuthenticationInstances[$className] = $defaultProviderAuthentication; + } + } + if (isset($this->providerAuthenticationInstances[$className])) { + $this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]); + } + $this->registeredIdsToClassNames[$metadata->getId()] = $className; + $this->registeredClassNamesToIds[$className] = $metadata->getId(); + } + /** + * Gets a list of all registered provider IDs. + * + * @since 0.1.0 + * + * @return list List of registered provider IDs. + */ + public function getRegisteredProviderIds(): array + { + return array_keys($this->registeredIdsToClassNames); + } + /** + * Checks if a provider is registered. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name to check. + * @return bool True if the provider is registered. + */ + public function hasProvider(string $idOrClassName): bool + { + return $this->isRegisteredId($idOrClassName) || $this->isRegisteredClassName($idOrClassName); + } + /** + * Gets the class name for a registered provider. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return class-string The provider class name. + * @throws InvalidArgumentException If the provider is not registered. + */ + public function getProviderClassName(string $idOrClassName): string + { + // If it's already a class name, return it + if ($this->isRegisteredClassName($idOrClassName)) { + return $idOrClassName; + } + // If it's a registered ID, return its class name + if ($this->isRegisteredId($idOrClassName)) { + return $this->registeredIdsToClassNames[$idOrClassName]; + } + // Not found + throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName)); + } + /** + * Gets the provider ID for a registered provider. + * + * @since 0.2.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return string The provider ID. + * @throws InvalidArgumentException If the provider is not registered. + */ + public function getProviderId(string $idOrClassName): string + { + // If it's already an ID, return it + if ($this->isRegisteredId($idOrClassName)) { + return $idOrClassName; + } + // If it's a registered class name, return its ID + if ($this->isRegisteredClassName($idOrClassName)) { + return $this->registeredClassNamesToIds[$idOrClassName]; + } + // Not found + throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName)); + } + /** + * Checks if a provider is properly configured. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return bool True if the provider is configured and ready to use. + */ + public function isProviderConfigured(string $idOrClassName): bool + { + try { + $className = $this->resolveProviderClassName($idOrClassName); + // Use static method from ProviderInterface + /** @var class-string $className */ + $availability = $className::availability(); + return $availability->isConfigured(); + } catch (InvalidArgumentException $e) { + return \false; + } + } + /** + * Finds models across all available providers that support the given requirements. + * + * @since 0.1.0 + * + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of provider models metadata that match requirements. + */ + public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array + { + $results = []; + foreach ($this->registeredIdsToClassNames as $providerId => $className) { + $providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements); + if (!empty($providerResults)) { + // Use static method from ProviderInterface + /** @var class-string $className */ + $providerMetadata = $className::metadata(); + $results[] = new ProviderModelsMetadata($providerMetadata, $providerResults); + } + } + return $results; + } + /** + * Finds models within a specific available provider that support the given requirements. + * + * @since 0.1.0 + * + * @param string $idOrClassName The provider ID or class name. + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of model metadata that match requirements. + */ + public function findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements): array + { + $className = $this->resolveProviderClassName($idOrClassName); + // If the provider is not configured, there is no way to use it, so it is considered unavailable. + if (!$this->isProviderConfigured($className)) { + return []; + } + $modelMetadataDirectory = $className::modelMetadataDirectory(); + // Filter models that meet requirements + $matchingModels = []; + foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) { + if ($modelRequirements->areMetBy($modelMetadata)) { + $matchingModels[] = $modelMetadata; + } + } + return $matchingModels; + } + /** + * Gets a configured model instance from a provider. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @param ModelConfig|null $modelConfig The model configuration. + * @return ModelInterface The configured model instance. + * @throws InvalidArgumentException If provider or model is not found. + */ + public function getProviderModel(string $idOrClassName, string $modelId, ?ModelConfig $modelConfig = null): ModelInterface + { + $className = $this->resolveProviderClassName($idOrClassName); + $modelInstance = $className::model($modelId, $modelConfig); + $this->bindModelDependencies($modelInstance); + return $modelInstance; + } + /** + * Binds dependencies to a model instance. + * + * This method injects required dependencies such as HTTP transporter + * and authentication into model instances that need them. + * + * @since 0.1.0 + * + * @param ModelInterface $modelInstance The model instance to bind dependencies to. + * @return void + */ + public function bindModelDependencies(ModelInterface $modelInstance): void + { + $className = $this->resolveProviderClassName($modelInstance->providerMetadata()->getId()); + if ($modelInstance instanceof WithHttpTransporterInterface) { + $modelInstance->setHttpTransporter($this->getHttpTransporter()); + } + if ($modelInstance instanceof WithRequestAuthenticationInterface) { + $requestAuthentication = $this->getProviderRequestAuthentication($className); + if ($requestAuthentication !== null) { + $modelInstance->setRequestAuthentication($requestAuthentication); + } + } + } + /** + * Gets the class name for a registered provider (handles both ID and class name input). + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return class-string The provider class name. + * @throws InvalidArgumentException If provider is not registered. + */ + private function resolveProviderClassName(string $idOrClassName): string + { + // If it's already a class name, return it + if ($this->isRegisteredClassName($idOrClassName)) { + return $idOrClassName; + } + // If it's a registered ID, return its class name + if ($this->isRegisteredId($idOrClassName)) { + return $this->registeredIdsToClassNames[$idOrClassName]; + } + // Not found + throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName)); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void + { + $this->setHttpTransporterOriginal($httpTransporter); + // Make sure all registered providers have the HTTP transporter hooked up as needed. + foreach ($this->registeredIdsToClassNames as $className) { + $this->setHttpTransporterForProvider($className, $httpTransporter); + } + } + /** + * Sets the request authentication instance for the given provider. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @param RequestAuthenticationInterface $requestAuthentication The request authentication instance. + */ + public function setProviderRequestAuthentication(string $idOrClassName, RequestAuthenticationInterface $requestAuthentication): void + { + $className = $this->resolveProviderClassName($idOrClassName); + $this->providerAuthenticationInstances[$className] = $requestAuthentication; + $this->setRequestAuthenticationForProvider($className, $requestAuthentication); + } + /** + * Gets the request authentication instance for the given provider, if set. + * + * @since 0.1.0 + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return ?RequestAuthenticationInterface The request authentication instance, or null if not set. + */ + public function getProviderRequestAuthentication(string $idOrClassName): ?RequestAuthenticationInterface + { + $className = $this->resolveProviderClassName($idOrClassName); + if (!isset($this->providerAuthenticationInstances[$className])) { + return null; + } + return $this->providerAuthenticationInstances[$className]; + } + /** + * Sets the HTTP transporter for a specific provider, hooking up its class instances. + * + * @since 0.1.0 + * + * @param class-string $className The provider class name. + * @param HttpTransporterInterface $httpTransporter The HTTP transporter instance. + */ + private function setHttpTransporterForProvider(string $className, HttpTransporterInterface $httpTransporter): void + { + $availability = $className::availability(); + if ($availability instanceof WithHttpTransporterInterface) { + $availability->setHttpTransporter($httpTransporter); + } + $modelMetadataDirectory = $className::modelMetadataDirectory(); + if ($modelMetadataDirectory instanceof WithHttpTransporterInterface) { + $modelMetadataDirectory->setHttpTransporter($httpTransporter); + } + if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) { + $operationsHandler = $className::operationsHandler(); + if ($operationsHandler instanceof WithHttpTransporterInterface) { + $operationsHandler->setHttpTransporter($httpTransporter); + } + } + } + /** + * Sets the request authentication for a specific provider, hooking up its class instances. + * + * @since 0.1.0 + * + * @param class-string $className The provider class name. + * @param RequestAuthenticationInterface $requestAuthentication The authentication instance. + * + * @throws InvalidArgumentException If the authentication instance is not of the expected type. + */ + private function setRequestAuthenticationForProvider(string $className, RequestAuthenticationInterface $requestAuthentication): void + { + $authenticationMethod = $className::metadata()->getAuthenticationMethod(); + if ($authenticationMethod === null) { + throw new InvalidArgumentException(sprintf('Provider %s does not expect any authentication, but got %s.', $className, get_class($requestAuthentication))); + } + $expectedClass = $authenticationMethod->getImplementationClass(); + if (!$requestAuthentication instanceof $expectedClass) { + throw new InvalidArgumentException(sprintf('Provider %s expects authentication of type %s, but got %s.', $className, $expectedClass, get_class($requestAuthentication))); + } + $availability = $className::availability(); + if ($availability instanceof WithRequestAuthenticationInterface) { + $availability->setRequestAuthentication($requestAuthentication); + } + $modelMetadataDirectory = $className::modelMetadataDirectory(); + if ($modelMetadataDirectory instanceof WithRequestAuthenticationInterface) { + $modelMetadataDirectory->setRequestAuthentication($requestAuthentication); + } + if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) { + $operationsHandler = $className::operationsHandler(); + if ($operationsHandler instanceof WithRequestAuthenticationInterface) { + $operationsHandler->setRequestAuthentication($requestAuthentication); + } + } + } + /** + * Creates a default request authentication instance for a provider. + * + * @since 0.1.0 + * + * @param class-string $className The provider class name. + * @return ?RequestAuthenticationInterface The default request authentication instance, or null if not required or + * if no credential data can be found. + */ + private function createDefaultProviderRequestAuthentication(string $className): ?RequestAuthenticationInterface + { + $providerMetadata = $className::metadata(); + $providerId = $providerMetadata->getId(); + $authenticationMethod = $providerMetadata->getAuthenticationMethod(); + if ($authenticationMethod === null) { + return null; + } + $authenticationClass = $authenticationMethod->getImplementationClass(); + if ($authenticationClass === null) { + return null; + } + $authenticationSchema = $authenticationClass::getJsonSchema(); + // Iterate over all JSON schema object properties to try to determine the necessary authentication data. + $authenticationData = []; + if (isset($authenticationSchema['properties']) && is_array($authenticationSchema['properties'])) { + /** @var array $details */ + foreach ($authenticationSchema['properties'] as $property => $details) { + $envVarName = $this->getEnvVarName($providerId, $property); + // Try to get the value from environment variable or constant. + $envValue = getenv($envVarName); + if ($envValue === \false) { + if (!defined($envVarName)) { + continue; + // Skip if neither environment variable nor constant is defined. + } + $envValue = constant($envVarName); + if (!is_scalar($envValue)) { + continue; + } + } + if (isset($details['type'])) { + switch ($details['type']) { + case 'boolean': + $authenticationData[$property] = filter_var($envValue, \FILTER_VALIDATE_BOOLEAN); + break; + case 'number': + $authenticationData[$property] = (int) $envValue; + break; + case 'string': + default: + $authenticationData[$property] = (string) $envValue; + } + } else { + // Default to string if no type is specified. + $authenticationData[$property] = (string) $envValue; + } + } + // If any required fields are missing, return null to avoid immediate errors. + if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) { + /** @var list $requiredProperties */ + $requiredProperties = $authenticationSchema['required']; + if (array_diff_key(array_flip($requiredProperties), $authenticationData)) { + return null; + } + } + } + /** @var RequestAuthenticationInterface */ + /** @var array $authenticationData */ + return $authenticationClass::fromArray($authenticationData); + } + /** + * Checks if the given value is a registered provider class name. + * + * @since 0.4.0 + * + * @param string $idOrClassName The value to check. + * @return bool True if it's a registered class name. + * @phpstan-assert-if-true class-string $idOrClassName + */ + private function isRegisteredClassName(string $idOrClassName): bool + { + return isset($this->registeredClassNamesToIds[$idOrClassName]); + } + /** + * Checks if the given value is a registered provider ID. + * + * @since 0.4.0 + * + * @param string $idOrClassName The value to check. + * @return bool True if it's a registered provider ID. + */ + private function isRegisteredId(string $idOrClassName): bool + { + return isset($this->registeredIdsToClassNames[$idOrClassName]); + } + /** + * Converts a provider ID and field name to a constant case environment variable name. + * + * @since 0.1.0 + * + * @param string $providerId The provider ID. + * @param string $field The field name. + * @return string The environment variable name in CONSTANT_CASE. + */ + private function getEnvVarName(string $providerId, string $field): string + { + // Convert camelCase or kebab-case or snake_case to CONSTANT_CASE. + $constantCaseProviderId = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $providerId))); + $constantCaseField = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $field))); + return "{$constantCaseProviderId}_{$constantCaseField}"; + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php b/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php new file mode 100644 index 0000000000000..5a087ca8b3fbe --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/Contracts/ResultInterface.php @@ -0,0 +1,59 @@ + Provider metadata. + */ + public function getAdditionalData(): array; +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php b/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php new file mode 100644 index 0000000000000..d1bf7e0782985 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/Candidate.php @@ -0,0 +1,117 @@ + + */ +class Candidate extends AbstractDataTransferObject +{ + public const KEY_MESSAGE = 'message'; + public const KEY_FINISH_REASON = 'finishReason'; + /** + * @var Message The generated message. + */ + private Message $message; + /** + * @var FinishReasonEnum The reason generation stopped. + */ + private FinishReasonEnum $finishReason; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param Message $message The generated message. + * @param FinishReasonEnum $finishReason The reason generation stopped. + */ + public function __construct(Message $message, FinishReasonEnum $finishReason) + { + if (!$message->getRole()->isModel()) { + throw new InvalidArgumentException('Message must be a model message.'); + } + $this->message = $message; + $this->finishReason = $finishReason; + } + /** + * Gets the generated message. + * + * @since 0.1.0 + * + * @return Message The message. + */ + public function getMessage(): Message + { + return $this->message; + } + /** + * Gets the finish reason. + * + * @since 0.1.0 + * + * @return FinishReasonEnum The finish reason. + */ + public function getFinishReason(): FinishReasonEnum + { + return $this->finishReason; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_MESSAGE => Message::getJsonSchema(), self::KEY_FINISH_REASON => ['type' => 'string', 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.']], 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return CandidateArrayShape + */ + public function toArray(): array + { + return [self::KEY_MESSAGE => $this->message->toArray(), self::KEY_FINISH_REASON => $this->finishReason->value]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON]); + $messageData = $array[self::KEY_MESSAGE]; + return new self(Message::fromArray($messageData), FinishReasonEnum::from($array[self::KEY_FINISH_REASON])); + } + /** + * Performs a deep clone of the candidate. + * + * This method ensures that the message object is cloned to prevent + * modifications to the cloned candidate from affecting the original. + * + * @since 0.4.1 + */ + public function __clone() + { + $this->message = clone $this->message; + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php b/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php new file mode 100644 index 0000000000000..0d1d0ccbc38c7 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php @@ -0,0 +1,420 @@ +, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata: ProviderMetadataArrayShape, + * modelMetadata: ModelMetadataArrayShape, + * additionalData?: array + * } + * + * @extends AbstractDataTransferObject + */ +class GenerativeAiResult extends AbstractDataTransferObject implements ResultInterface +{ + public const KEY_ID = 'id'; + public const KEY_CANDIDATES = 'candidates'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; + public const KEY_MODEL_METADATA = 'modelMetadata'; + public const KEY_ADDITIONAL_DATA = 'additionalData'; + /** + * @var string Unique identifier for this result. + */ + private string $id; + /** + * @var Candidate[] The generated candidates. + */ + private array $candidates; + /** + * @var TokenUsage Token usage statistics. + */ + private \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage; + /** + * @var ProviderMetadata Provider metadata. + */ + private ProviderMetadata $providerMetadata; + /** + * @var ModelMetadata Model metadata. + */ + private ModelMetadata $modelMetadata; + /** + * @var array Additional data. + */ + private array $additionalData; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $id Unique identifier for this result. + * @param Candidate[] $candidates The generated candidates. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param ProviderMetadata $providerMetadata Provider metadata. + * @param ModelMetadata $modelMetadata Model metadata. + * @param array $additionalData Additional data. + * @throws InvalidArgumentException If no candidates provided. + */ + public function __construct(string $id, array $candidates, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = []) + { + if (empty($candidates)) { + throw new InvalidArgumentException('At least one candidate must be provided'); + } + $this->id = $id; + $this->candidates = $candidates; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + $this->additionalData = $additionalData; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getId(): string + { + return $this->id; + } + /** + * Gets the generated candidates. + * + * @since 0.1.0 + * + * @return Candidate[] The candidates. + */ + public function getCandidates(): array + { + return $this->candidates; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage + { + return $this->tokenUsage; + } + /** + * Gets the provider metadata. + * + * @since 0.1.0 + * + * @return ProviderMetadata The provider metadata. + */ + public function getProviderMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + /** + * Gets the model metadata. + * + * @since 0.1.0 + * + * @return ModelMetadata The model metadata. + */ + public function getModelMetadata(): ModelMetadata + { + return $this->modelMetadata; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } + /** + * Gets the total number of candidates. + * + * @since 0.1.0 + * + * @return int The total number of candidates. + */ + public function getCandidateCount(): int + { + return count($this->candidates); + } + /** + * Checks if the result has multiple candidates. + * + * @since 0.1.0 + * + * @return bool True if there are multiple candidates, false otherwise. + */ + public function hasMultipleCandidates(): bool + { + return $this->getCandidateCount() > 1; + } + /** + * Converts the first candidate to text. + * + * Only text from the content channel is considered. Text within model thought or reasoning is ignored. + * + * @since 0.1.0 + * + * @return string The text content. + * @throws RuntimeException If no text content. + */ + public function toText(): string + { + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + $channel = $part->getChannel(); + $text = $part->getText(); + if ($channel->isContent() && $text !== null) { + return $text; + } + } + throw new RuntimeException('No text content found in first candidate'); + } + /** + * Converts the first candidate to a file. + * + * Only files from the content channel are considered. Files within model thought or reasoning are ignored. + * + * @since 0.1.0 + * + * @return File The file. + * @throws RuntimeException If no file content. + */ + public function toFile(): File + { + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + $channel = $part->getChannel(); + $file = $part->getFile(); + if ($channel->isContent() && $file !== null) { + return $file; + } + } + throw new RuntimeException('No file content found in first candidate'); + } + /** + * Converts the first candidate to an image file. + * + * @since 0.1.0 + * + * @return File The image file. + * @throws RuntimeException If no image content. + */ + public function toImageFile(): File + { + $file = $this->toFile(); + if (!$file->isImage()) { + throw new RuntimeException(sprintf('File is not an image. MIME type: %s', $file->getMimeType())); + } + return $file; + } + /** + * Converts the first candidate to an audio file. + * + * @since 0.1.0 + * + * @return File The audio file. + * @throws RuntimeException If no audio content. + */ + public function toAudioFile(): File + { + $file = $this->toFile(); + if (!$file->isAudio()) { + throw new RuntimeException(sprintf('File is not an audio file. MIME type: %s', $file->getMimeType())); + } + return $file; + } + /** + * Converts the first candidate to a video file. + * + * @since 0.1.0 + * + * @return File The video file. + * @throws RuntimeException If no video content. + */ + public function toVideoFile(): File + { + $file = $this->toFile(); + if (!$file->isVideo()) { + throw new RuntimeException(sprintf('File is not a video file. MIME type: %s', $file->getMimeType())); + } + return $file; + } + /** + * Converts the first candidate to a message. + * + * @since 0.1.0 + * + * @return Message The message. + */ + public function toMessage(): Message + { + return $this->candidates[0]->getMessage(); + } + /** + * Converts all candidates to text. + * + * @since 0.1.0 + * + * @return list Array of text content. + */ + public function toTexts(): array + { + $texts = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $channel = $part->getChannel(); + $text = $part->getText(); + if ($channel->isContent() && $text !== null) { + $texts[] = $text; + break; + } + } + } + return $texts; + } + /** + * Converts all candidates to files. + * + * @since 0.1.0 + * + * @return list Array of files. + */ + public function toFiles(): array + { + $files = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $channel = $part->getChannel(); + $file = $part->getFile(); + if ($channel->isContent() && $file !== null) { + $files[] = $file; + break; + } + } + } + return $files; + } + /** + * Converts all candidates to image files. + * + * @since 0.1.0 + * + * @return list Array of image files. + */ + public function toImageFiles(): array + { + return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isImage())); + } + /** + * Converts all candidates to audio files. + * + * @since 0.1.0 + * + * @return list Array of audio files. + */ + public function toAudioFiles(): array + { + return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isAudio())); + } + /** + * Converts all candidates to video files. + * + * @since 0.1.0 + * + * @return list Array of video files. + */ + public function toVideoFiles(): array + { + return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isVideo())); + } + /** + * Converts all candidates to messages. + * + * @since 0.1.0 + * + * @return list Array of messages. + */ + public function toMessages(): array + { + return array_values(array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->getMessage(), $this->candidates)); + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_CANDIDATES => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Candidate::getJsonSchema(), 'minItems' => 1, 'description' => 'The generated candidates.'], self::KEY_TOKEN_USAGE => \WordPress\AiClient\Results\DTO\TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), self::KEY_ADDITIONAL_DATA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Additional data included in the API response.']], 'required' => [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return GenerativeAiResultArrayShape + */ + public function toArray(): array + { + return [self::KEY_ID => $this->id, self::KEY_CANDIDATES => array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->toArray(), $this->candidates), self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), self::KEY_ADDITIONAL_DATA => $this->additionalData]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]); + $candidates = array_map(fn(array $candidateData) => \WordPress\AiClient\Results\DTO\Candidate::fromArray($candidateData), $array[self::KEY_CANDIDATES]); + return new self($array[self::KEY_ID], $candidates, \WordPress\AiClient\Results\DTO\TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), $array[self::KEY_ADDITIONAL_DATA] ?? []); + } + /** + * Performs a deep clone of the result. + * + * This method ensures that all nested objects (candidates, token usage, metadata) + * are cloned to prevent modifications to the cloned result from affecting the original. + * + * @since 0.4.1 + */ + public function __clone() + { + $clonedCandidates = []; + foreach ($this->candidates as $candidate) { + $clonedCandidates[] = clone $candidate; + } + $this->candidates = $clonedCandidates; + $this->tokenUsage = clone $this->tokenUsage; + $this->providerMetadata = clone $this->providerMetadata; + $this->modelMetadata = clone $this->modelMetadata; + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php new file mode 100644 index 0000000000000..df3201c92f77d --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php @@ -0,0 +1,118 @@ + + */ +class TokenUsage extends AbstractDataTransferObject +{ + public const KEY_PROMPT_TOKENS = 'promptTokens'; + public const KEY_COMPLETION_TOKENS = 'completionTokens'; + public const KEY_TOTAL_TOKENS = 'totalTokens'; + /** + * @var int Number of tokens in the prompt. + */ + private int $promptTokens; + /** + * @var int Number of tokens in the completion. + */ + private int $completionTokens; + /** + * @var int Total number of tokens used. + */ + private int $totalTokens; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param int $promptTokens Number of tokens in the prompt. + * @param int $completionTokens Number of tokens in the completion. + * @param int $totalTokens Total number of tokens used. + */ + public function __construct(int $promptTokens, int $completionTokens, int $totalTokens) + { + $this->promptTokens = $promptTokens; + $this->completionTokens = $completionTokens; + $this->totalTokens = $totalTokens; + } + /** + * Gets the number of prompt tokens. + * + * @since 0.1.0 + * + * @return int The prompt token count. + */ + public function getPromptTokens(): int + { + return $this->promptTokens; + } + /** + * Gets the number of completion tokens. + * + * @since 0.1.0 + * + * @return int The completion token count. + */ + public function getCompletionTokens(): int + { + return $this->completionTokens; + } + /** + * Gets the total number of tokens. + * + * @since 0.1.0 + * + * @return int The total token count. + */ + public function getTotalTokens(): int + { + return $this->totalTokens; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return TokenUsageArrayShape + */ + public function toArray(): array + { + return [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]); + return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS]); + } +} diff --git a/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php b/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php new file mode 100644 index 0000000000000..b0c61b3fbe359 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Results/Enums/FinishReasonEnum.php @@ -0,0 +1,45 @@ + + */ +class FunctionCall extends AbstractDataTransferObject +{ + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_ARGS = 'args'; + /** + * @var string|null Unique identifier for this function call. + */ + private ?string $id; + /** + * @var string|null The name of the function to call. + */ + private ?string $name; + /** + * @var mixed The arguments to pass to the function. + */ + private $args; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string|null $id Unique identifier for this function call. + * @param string|null $name The name of the function to call. + * @param mixed $args The arguments to pass to the function. + * @throws InvalidArgumentException If neither id nor name is provided. + */ + public function __construct(?string $id = null, ?string $name = null, $args = null) + { + if ($id === null && $name === null) { + throw new InvalidArgumentException('At least one of id or name must be provided.'); + } + $this->id = $id; + $this->name = $name; + $this->args = $args; + } + /** + * Gets the function call ID. + * + * @since 0.1.0 + * + * @return string|null The function call ID. + */ + public function getId(): ?string + { + return $this->id; + } + /** + * Gets the function name. + * + * @since 0.1.0 + * + * @return string|null The function name. + */ + public function getName(): ?string + { + return $this->name; + } + /** + * Gets the function arguments. + * + * @since 0.1.0 + * + * @return mixed The function arguments. + */ + public function getArgs() + { + return $this->args; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this function call.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function to call.'], self::KEY_ARGS => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The arguments to pass to the function.']], 'oneOf' => [['required' => [self::KEY_ID]], ['required' => [self::KEY_NAME]]]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return FunctionCallArrayShape + */ + public function toArray(): array + { + $data = []; + if ($this->id !== null) { + $data[self::KEY_ID] = $this->id; + } + if ($this->name !== null) { + $data[self::KEY_NAME] = $this->name; + } + if ($this->args !== null) { + $data[self::KEY_ARGS] = $this->args; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_ARGS] ?? null); + } +} diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php new file mode 100644 index 0000000000000..935459f44ec0a --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionDeclaration.php @@ -0,0 +1,122 @@ + + * } + * + * @extends AbstractDataTransferObject + */ +class FunctionDeclaration extends AbstractDataTransferObject +{ + public const KEY_NAME = 'name'; + public const KEY_DESCRIPTION = 'description'; + public const KEY_PARAMETERS = 'parameters'; + /** + * @var string The name of the function. + */ + private string $name; + /** + * @var string A description of what the function does. + */ + private string $description; + /** + * @var array|null The JSON schema for the function parameters. + */ + private ?array $parameters; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $name The name of the function. + * @param string $description A description of what the function does. + * @param array|null $parameters The JSON schema for the function parameters. + */ + public function __construct(string $name, string $description, ?array $parameters = null) + { + $this->name = $name; + $this->description = $description; + $this->parameters = $parameters; + } + /** + * Gets the function name. + * + * @since 0.1.0 + * + * @return string The function name. + */ + public function getName(): string + { + return $this->name; + } + /** + * Gets the function description. + * + * @since 0.1.0 + * + * @return string The function description. + */ + public function getDescription(): string + { + return $this->description; + } + /** + * Gets the function parameters schema. + * + * @since 0.1.0 + * + * @return array|null The parameters schema. + */ + public function getParameters(): ?array + { + return $this->parameters; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'A description of what the function does.'], self::KEY_PARAMETERS => ['type' => 'object', 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => \true]], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return FunctionDeclarationArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description]; + if ($this->parameters !== null) { + $data[self::KEY_PARAMETERS] = $this->parameters; + } + return $data; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_DESCRIPTION]); + return new self($array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null); + } +} diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php new file mode 100644 index 0000000000000..ced268261387c --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Tools/DTO/FunctionResponse.php @@ -0,0 +1,119 @@ + + */ +class FunctionResponse extends AbstractDataTransferObject +{ + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_RESPONSE = 'response'; + /** + * @var string The ID of the function call this is responding to. + */ + private string $id; + /** + * @var string The name of the function that was called. + */ + private string $name; + /** + * @var mixed The response data from the function. + */ + private $response; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string $id The ID of the function call this is responding to. + * @param string $name The name of the function that was called. + * @param mixed $response The response data from the function. + */ + public function __construct(string $id, string $name, $response) + { + $this->id = $id; + $this->name = $name; + $this->response = $response; + } + /** + * Gets the function call ID. + * + * @since 0.1.0 + * + * @return string|null The function call ID. + */ + public function getId(): ?string + { + return $this->id; + } + /** + * Gets the function name. + * + * @since 0.1.0 + * + * @return string|null The function name. + */ + public function getName(): ?string + { + return $this->name; + } + /** + * Gets the function response. + * + * @since 0.1.0 + * + * @return mixed The response data. + */ + public function getResponse() + { + return $this->response; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The ID of the function call this is responding to.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function that was called.'], self::KEY_RESPONSE => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The response data from the function.']], 'oneOf' => [['required' => [self::KEY_RESPONSE, self::KEY_ID]], ['required' => [self::KEY_RESPONSE, self::KEY_NAME]]]]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return FunctionResponseArrayShape + */ + public function toArray(): array + { + return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_RESPONSE => $this->response]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_RESPONSE]); + // Validate that at least one of id or name is provided + if (!array_key_exists(self::KEY_ID, $array) && !array_key_exists(self::KEY_NAME, $array)) { + throw new InvalidArgumentException('At least one of id or name must be provided.'); + } + return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_RESPONSE]); + } +} diff --git a/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php b/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php new file mode 100644 index 0000000000000..3ce1c62d37099 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Tools/DTO/WebSearch.php @@ -0,0 +1,95 @@ + + */ +class WebSearch extends AbstractDataTransferObject +{ + public const KEY_ALLOWED_DOMAINS = 'allowedDomains'; + public const KEY_DISALLOWED_DOMAINS = 'disallowedDomains'; + /** + * @var string[] List of domains that are allowed for web search. + */ + private array $allowedDomains; + /** + * @var string[] List of domains that are disallowed for web search. + */ + private array $disallowedDomains; + /** + * Constructor. + * + * @since 0.1.0 + * + * @param string[] $allowedDomains List of domains that are allowed for web search. + * @param string[] $disallowedDomains List of domains that are disallowed for web search. + */ + public function __construct(array $allowedDomains = [], array $disallowedDomains = []) + { + $this->allowedDomains = $allowedDomains; + $this->disallowedDomains = $disallowedDomains; + } + /** + * Gets the allowed domains. + * + * @since 0.1.0 + * + * @return string[] The allowed domains. + */ + public function getAllowedDomains(): array + { + return $this->allowedDomains; + } + /** + * Gets the disallowed domains. + * + * @since 0.1.0 + * + * @return string[] The disallowed domains. + */ + public function getDisallowedDomains(): array + { + return $this->disallowedDomains; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function getJsonSchema(): array + { + return ['type' => 'object', 'properties' => [self::KEY_ALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are allowed for web search.'], self::KEY_DISALLOWED_DOMAINS => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'List of domains that are disallowed for web search.']], 'required' => []]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + * + * @return WebSearchArrayShape + */ + public function toArray(): array + { + return [self::KEY_ALLOWED_DOMAINS => $this->allowedDomains, self::KEY_DISALLOWED_DOMAINS => $this->disallowedDomains]; + } + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public static function fromArray(array $array): self + { + return new self($array[self::KEY_ALLOWED_DOMAINS] ?? [], $array[self::KEY_DISALLOWED_DOMAINS] ?? []); + } +} diff --git a/src/wp-includes/php-ai-client/src/polyfills.php b/src/wp-includes/php-ai-client/src/polyfills.php new file mode 100644 index 0000000000000..20bb0fede1c0b --- /dev/null +++ b/src/wp-includes/php-ai-client/src/polyfills.php @@ -0,0 +1,91 @@ + $array The array to check. + * @return bool True if the array is a list, false otherwise. + */ + function array_is_list(array $array): bool + { + if ($array === []) { + return \true; + } + $expectedKey = 0; + foreach (\array_keys($array) as $key) { + if ($key !== $expectedKey) { + return \false; + } + $expectedKey++; + } + return \true; + } +} +if (!\function_exists('str_starts_with') && !\function_exists('WordPress\AiClientDependencies\str_starts_with')) { + /** + * Checks if a string starts with a given substring. + * + * @since 0.1.0 + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for. + * @return bool True if $haystack starts with $needle, false otherwise. + */ + function str_starts_with(string $haystack, string $needle): bool + { + if ('' === $needle) { + return \true; + } + return 0 === \strpos($haystack, $needle); + } +} +if (!\function_exists('str_contains') && !\function_exists('WordPress\AiClientDependencies\str_contains')) { + /** + * Checks if a string contains a given substring. + * + * @since 0.1.0 + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for. + * @return bool True if $haystack contains $needle, false otherwise. + */ + function str_contains(string $haystack, string $needle): bool + { + if ('' === $needle) { + return \true; + } + return \false !== \strpos($haystack, $needle); + } +} +if (!\function_exists('str_ends_with') && !\function_exists('WordPress\AiClientDependencies\str_ends_with')) { + /** + * Checks if a string ends with a given substring. + * + * @since 0.1.0 + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for. + * @return bool True if $haystack ends with $needle, false otherwise. + */ + function str_ends_with(string $haystack, string $needle): bool + { + if ('' === $haystack) { + return '' === $needle; + } + $len = \strlen($needle); + return \substr($haystack, -$len, $len) === $needle; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php new file mode 100644 index 0000000000000..f84213a167212 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php @@ -0,0 +1,13 @@ + + */ +interface Exception extends PsrClientException +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php new file mode 100644 index 0000000000000..6e05303eaafc7 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php @@ -0,0 +1,46 @@ + + */ +class HttpException extends RequestException +{ + /** + * @var ResponseInterface + */ + protected $response; + /** + * @param string $message + */ + public function __construct($message, RequestInterface $request, ResponseInterface $response, ?\Exception $previous = null) + { + parent::__construct($message, $request, $previous); + $this->response = $response; + $this->code = $response->getStatusCode(); + } + /** + * Returns the response. + * + * @return ResponseInterface + */ + public function getResponse() + { + return $this->response; + } + /** + * Factory method to create a new exception with a normalized error message. + */ + public static function create(RequestInterface $request, ResponseInterface $response, ?\Exception $previous = null) + { + $message = sprintf('[url] %s [http method] %s [status code] %s [reason phrase] %s', $request->getRequestTarget(), $request->getMethod(), $response->getStatusCode(), $response->getReasonPhrase()); + return new static($message, $request, $response, $previous); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php new file mode 100644 index 0000000000000..ece5bdf587362 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php @@ -0,0 +1,25 @@ + + */ +class NetworkException extends TransferException implements PsrNetworkException +{ + use RequestAwareTrait; + /** + * @param string $message + */ + public function __construct($message, RequestInterface $request, ?\Exception $previous = null) + { + $this->setRequest($request); + parent::__construct($message, 0, $previous); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php new file mode 100644 index 0000000000000..fe337b0a34675 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php @@ -0,0 +1,20 @@ +request = $request; + } + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php new file mode 100644 index 0000000000000..ec080724b889b --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php @@ -0,0 +1,26 @@ + + */ +class RequestException extends TransferException implements PsrRequestException +{ + use RequestAwareTrait; + /** + * @param string $message + */ + public function __construct($message, RequestInterface $request, ?\Exception $previous = null) + { + $this->setRequest($request); + parent::__construct($message, 0, $previous); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php new file mode 100644 index 0000000000000..7caf710ef27c3 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php @@ -0,0 +1,13 @@ + + */ +class TransferException extends \RuntimeException implements Exception +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php new file mode 100644 index 0000000000000..4b45bdf90f554 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php @@ -0,0 +1,24 @@ + + */ +interface HttpAsyncClient +{ + /** + * Sends a PSR-7 request in an asynchronous way. + * + * Exceptions related to processing the request are available from the returned Promise. + * + * @return Promise resolves a PSR-7 Response or fails with an Http\Client\Exception + * + * @throws \Exception If processing the request is impossible (eg. bad configuration). + */ + public function sendAsyncRequest(RequestInterface $request); +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php new file mode 100644 index 0000000000000..244b9ddb7dbc6 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php @@ -0,0 +1,16 @@ +response = $response; + } + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + if (null === $onFulfilled) { + return $this; + } + try { + return new self($onFulfilled($this->response)); + } catch (Exception $e) { + return new HttpRejectedPromise($e); + } + } + public function getState() + { + return Promise::FULFILLED; + } + public function wait($unwrap = \true) + { + if ($unwrap) { + return $this->response; + } + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php new file mode 100644 index 0000000000000..5541415fdf166 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php @@ -0,0 +1,42 @@ +exception = $exception; + } + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + if (null === $onRejected) { + return $this; + } + try { + $result = $onRejected($this->exception); + if ($result instanceof Promise) { + return $result; + } + return new HttpFulfilledPromise($result); + } catch (Exception $e) { + return new self($e); + } + } + public function getState() + { + return Promise::REJECTED; + } + public function wait($unwrap = \true) + { + if ($unwrap) { + throw $this->exception; + } + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php new file mode 100644 index 0000000000000..50c622f8bb6ba --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/ClassDiscovery.php @@ -0,0 +1,219 @@ + + * @author Márk Sági-Kazár + * @author Tobias Nyholm + */ +abstract class ClassDiscovery +{ + /** + * A list of strategies to find classes. + * + * @var DiscoveryStrategy[] + */ + private static $strategies = [Strategy\GeneratedDiscoveryStrategy::class, Strategy\CommonClassesStrategy::class, Strategy\CommonPsr17ClassesStrategy::class, Strategy\PuliBetaStrategy::class]; + private static $deprecatedStrategies = [Strategy\PuliBetaStrategy::class => \true]; + /** + * Discovery cache to make the second time we use discovery faster. + * + * @var array + */ + private static $cache = []; + /** + * Finds a class. + * + * @param string $type + * + * @return string|\Closure + * + * @throws DiscoveryFailedException + */ + protected static function findOneByType($type) + { + // Look in the cache + if (null !== $class = self::getFromCache($type)) { + return $class; + } + static $skipStrategy; + $skipStrategy ?? $skipStrategy = self::safeClassExists(Strategy\GeneratedDiscoveryStrategy::class) ? \false : Strategy\GeneratedDiscoveryStrategy::class; + $exceptions = []; + foreach (self::$strategies as $strategy) { + if ($skipStrategy === $strategy) { + continue; + } + try { + $candidates = $strategy::getCandidates($type); + } catch (StrategyUnavailableException $e) { + if (!isset(self::$deprecatedStrategies[$strategy])) { + $exceptions[] = $e; + } + continue; + } + foreach ($candidates as $candidate) { + if (isset($candidate['condition'])) { + if (!self::evaluateCondition($candidate['condition'])) { + continue; + } + } + // save the result for later use + self::storeInCache($type, $candidate); + return $candidate['class']; + } + $exceptions[] = new NoCandidateFoundException($strategy, $candidates); + } + throw DiscoveryFailedException::create($exceptions); + } + /** + * Get a value from cache. + * + * @param string $type + * + * @return string|null + */ + private static function getFromCache($type) + { + if (!isset(self::$cache[$type])) { + return; + } + $candidate = self::$cache[$type]; + if (isset($candidate['condition'])) { + if (!self::evaluateCondition($candidate['condition'])) { + return; + } + } + return $candidate['class']; + } + /** + * Store a value in cache. + * + * @param string $type + * @param string $class + */ + private static function storeInCache($type, $class) + { + self::$cache[$type] = $class; + } + /** + * Set new strategies and clear the cache. + * + * @param string[] $strategies list of fully qualified class names that implement DiscoveryStrategy + */ + public static function setStrategies(array $strategies) + { + self::$strategies = $strategies; + self::clearCache(); + } + /** + * Returns the currently configured discovery strategies as fully qualified class names. + * + * @return string[] + */ + public static function getStrategies(): iterable + { + return self::$strategies; + } + /** + * Append a strategy at the end of the strategy queue. + * + * @param string $strategy Fully qualified class name of a DiscoveryStrategy + */ + public static function appendStrategy($strategy) + { + self::$strategies[] = $strategy; + self::clearCache(); + } + /** + * Prepend a strategy at the beginning of the strategy queue. + * + * @param string $strategy Fully qualified class name to a DiscoveryStrategy + */ + public static function prependStrategy($strategy) + { + array_unshift(self::$strategies, $strategy); + self::clearCache(); + } + public static function clearCache() + { + self::$cache = []; + } + /** + * Evaluates conditions to boolean. + * + * @return bool + */ + protected static function evaluateCondition($condition) + { + if (is_string($condition)) { + // Should be extended for functions, extensions??? + return self::safeClassExists($condition); + } + if (is_callable($condition)) { + return (bool) $condition(); + } + if (is_bool($condition)) { + return $condition; + } + if (is_array($condition)) { + foreach ($condition as $c) { + if (\false === static::evaluateCondition($c)) { + // Immediately stop execution if the condition is false + return \false; + } + } + return \true; + } + return \false; + } + /** + * Get an instance of the $class. + * + * @param string|\Closure $class a FQCN of a class or a closure that instantiate the class + * + * @return object + * + * @throws ClassInstantiationFailedException + */ + protected static function instantiateClass($class) + { + try { + if (is_string($class)) { + return new $class(); + } + if (is_callable($class)) { + return $class(); + } + } catch (\Exception $e) { + throw new ClassInstantiationFailedException('Unexpected exception when instantiating class.', 0, $e); + } + throw new ClassInstantiationFailedException('Could not instantiate class because parameter is neither a callable nor a string'); + } + /** + * We need a "safe" version of PHP's "class_exists" because Magento has a bug + * (or they call it a "feature"). Magento is throwing an exception if you do class_exists() + * on a class that ends with "Factory" and if that file does not exits. + * + * This function catches all potential exceptions and makes sure to always return a boolean. + * + * @param string $class + * + * @return bool + */ + public static function safeClassExists($class) + { + try { + return class_exists($class) || interface_exists($class); + } catch (\Exception $e) { + return \false; + } + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php new file mode 100644 index 0000000000000..ed28ffc0b06a4 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php @@ -0,0 +1,319 @@ + + * + * @internal + */ +class Plugin implements PluginInterface, EventSubscriberInterface +{ + /** + * Describes, for every supported virtual implementation, which packages + * provide said implementation and which extra dependencies each package + * requires to provide the implementation. + */ + private const PROVIDE_RULES = ['php-http/async-client-implementation' => ['symfony/http-client:>=6.3' => ['guzzlehttp/promises', 'psr/http-factory-implementation', 'php-http/httplug'], 'symfony/http-client' => ['guzzlehttp/promises', 'php-http/message-factory', 'psr/http-factory-implementation', 'php-http/httplug'], 'php-http/guzzle7-adapter' => [], 'php-http/guzzle6-adapter' => [], 'php-http/curl-client' => [], 'php-http/react-adapter' => []], 'php-http/client-implementation' => ['symfony/http-client:>=6.3' => ['psr/http-factory-implementation', 'php-http/httplug'], 'symfony/http-client' => ['php-http/message-factory', 'psr/http-factory-implementation', 'php-http/httplug'], 'php-http/guzzle7-adapter' => [], 'php-http/guzzle6-adapter' => [], 'php-http/cakephp-adapter' => [], 'php-http/curl-client' => [], 'php-http/react-adapter' => [], 'php-http/buzz-adapter' => [], 'php-http/artax-adapter' => [], 'kriswallsmith/buzz:^1' => []], 'psr/http-client-implementation' => ['symfony/http-client' => ['psr/http-factory-implementation', 'psr/http-client'], 'guzzlehttp/guzzle' => [], 'kriswallsmith/buzz:^1' => []], 'psr/http-message-implementation' => ['php-http/discovery' => ['psr/http-factory-implementation']], 'psr/http-factory-implementation' => ['nyholm/psr7' => [], 'guzzlehttp/psr7:>=2' => [], 'slim/psr7' => [], 'laminas/laminas-diactoros' => [], 'phalcon/cphalcon:^4' => [], 'http-interop/http-factory-guzzle' => [], 'http-interop/http-factory-diactoros' => [], 'http-interop/http-factory-slim' => [], 'httpsoft/http-message' => []]]; + /** + * Describes which package should be preferred on the left side + * depending on which one is already installed on the right side. + */ + private const STICKYNESS_RULES = ['symfony/http-client' => 'symfony/framework-bundle', 'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7', 'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6', 'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5', 'php-http/cakephp-adapter' => 'cakephp/cakephp', 'php-http/react-adapter' => 'react/event-loop', 'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1', 'php-http/artax-adapter' => 'amphp/artax:^3', 'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1', 'http-interop/http-factory-slim' => 'slim/slim:^3']; + private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['Psr\Http\Message\RequestFactoryInterface', 'Psr\Http\Message\ResponseFactoryInterface', 'Psr\Http\Message\ServerRequestFactoryInterface', 'Psr\Http\Message\StreamFactoryInterface', 'Psr\Http\Message\UploadedFileFactoryInterface', 'Psr\Http\Message\UriFactoryInterface']]; + public static function getSubscribedEvents(): array + { + return [ScriptEvents::PRE_AUTOLOAD_DUMP => 'preAutoloadDump', ScriptEvents::POST_UPDATE_CMD => 'postUpdate']; + } + public function activate(Composer $composer, IOInterface $io): void + { + } + public function deactivate(Composer $composer, IOInterface $io) + { + } + public function uninstall(Composer $composer, IOInterface $io) + { + } + public function postUpdate(Event $event) + { + $composer = $event->getComposer(); + $repo = $composer->getRepositoryManager()->getLocalRepository(); + $requires = [$composer->getPackage()->getRequires(), $composer->getPackage()->getDevRequires()]; + $pinnedAbstractions = []; + $pinned = $composer->getPackage()->getExtra()['discovery'] ?? []; + foreach (self::INTERFACE_MAP as $abstraction => $interfaces) { + foreach (isset($pinned[$abstraction]) ? [] : $interfaces as $interface) { + if (!isset($pinned[$interface])) { + continue 2; + } + } + $pinnedAbstractions[$abstraction] = \true; + } + $missingRequires = $this->getMissingRequires($repo, $requires, 'project' === $composer->getPackage()->getType(), $pinnedAbstractions); + $missingRequires = ['require' => array_fill_keys(array_merge([], ...array_values($missingRequires[0])), '*'), 'require-dev' => array_fill_keys(array_merge([], ...array_values($missingRequires[1])), '*'), 'remove' => array_fill_keys(array_merge([], ...array_values($missingRequires[2])), '*')]; + if (!$missingRequires = array_filter($missingRequires)) { + return; + } + $composerJsonContents = file_get_contents(Factory::getComposerFile()); + $this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages')); + $installer = null; + // Find the composer installer, hack borrowed from symfony/flex + foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof Installer) { + $installer = $trace['object']; + break; + } + } + if (!$installer) { + return; + } + $event->stopPropagation(); + $dispatcher = $composer->getEventDispatcher(); + $disableScripts = !method_exists($dispatcher, 'setRunScripts') || !((array) $dispatcher)["\x00*\x00runScripts"]; + $composer = Factory::create($event->getIO(), null, \false, $disableScripts); + /** @var Installer $installer */ + $installer = clone $installer; + if (method_exists($installer, 'setAudit')) { + $trace['object']->setAudit(\false); + } + // we need a clone of the installer to preserve its configuration state but with our own service objects + $installer->__construct($event->getIO(), $composer->getConfig(), $composer->getPackage(), $composer->getDownloadManager(), $composer->getRepositoryManager(), $composer->getLocker(), $composer->getInstallationManager(), $composer->getEventDispatcher(), $composer->getAutoloadGenerator()); + if (method_exists($installer, 'setPlatformRequirementFilter')) { + $installer->setPlatformRequirementFilter(((array) $trace['object'])["\x00*\x00platformRequirementFilter"]); + } + if (0 !== $installer->run()) { + file_put_contents(Factory::getComposerFile(), $composerJsonContents); + return; + } + $versionSelector = new VersionSelector(ClassDiscovery::safeClassExists(RepositorySet::class) ? new RepositorySet() : new Pool()); + $updateComposerJson = \false; + foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) { + foreach (['require', 'require-dev'] as $key) { + if (!isset($missingRequires[$key][$package->getName()])) { + continue; + } + $updateComposerJson = \true; + $missingRequires[$key][$package->getName()] = $versionSelector->findRecommendedRequireVersion($package); + } + } + if ($updateComposerJson) { + $this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages')); + $this->updateComposerLock($composer, $event->getIO()); + } + } + public function getMissingRequires(InstalledRepositoryInterface $repo, array $requires, bool $isProject, array $pinnedAbstractions): array + { + $allPackages = []; + $devPackages = method_exists($repo, 'getDevPackageNames') ? array_fill_keys($repo->getDevPackageNames(), \true) : []; + // One must require "php-http/discovery" + // to opt-in for auto-installation of virtual package implementations + if (!isset($requires[0]['php-http/discovery'])) { + $requires = [[], []]; + } + foreach ($repo->getPackages() as $package) { + $allPackages[$package->getName()] = \true; + if (1 < \count($names = $package->getNames(\false))) { + $allPackages += array_fill_keys($names, \false); + if (isset($devPackages[$package->getName()])) { + $devPackages += $names; + } + } + if (isset($package->getRequires()['php-http/discovery'])) { + $requires[(int) isset($devPackages[$package->getName()])] += $package->getRequires(); + } + } + $missingRequires = [[], [], []]; + $versionParser = new VersionParser(); + if (ClassDiscovery::safeClassExists(\WordPress\AiClientDependencies\Phalcon\Http\Message\RequestFactory::class, \false)) { + $missingRequires[0]['psr/http-factory-implementation'] = []; + $missingRequires[1]['psr/http-factory-implementation'] = []; + } + foreach ($requires as $dev => $rules) { + $abstractions = []; + $rules = array_intersect_key(self::PROVIDE_RULES, $rules); + while ($rules) { + $abstraction = key($rules); + if (isset($pinnedAbstractions[$abstraction])) { + unset($rules[$abstraction]); + continue; + } + $abstractions[] = $abstraction; + foreach (array_shift($rules) as $candidate => $deps) { + [$candidate, $version] = explode(':', $candidate, 2) + [1 => null]; + if (!isset($allPackages[$candidate])) { + continue; + } + if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) { + continue; + } + if ($isProject && !$dev && isset($devPackages[$candidate])) { + $missingRequires[0][$abstraction] = [$candidate]; + $missingRequires[2][$abstraction] = [$candidate]; + } else { + $missingRequires[$dev][$abstraction] = []; + } + foreach ($deps as $dep) { + if (isset(self::PROVIDE_RULES[$dep])) { + $rules[$dep] = self::PROVIDE_RULES[$dep]; + } elseif (!isset($allPackages[$dep])) { + $missingRequires[$dev][$abstraction][] = $dep; + } elseif ($isProject && !$dev && isset($devPackages[$dep])) { + $missingRequires[0][$abstraction][] = $dep; + $missingRequires[2][$abstraction][] = $dep; + } + } + break; + } + } + while ($abstractions) { + $abstraction = array_shift($abstractions); + if (isset($missingRequires[$dev][$abstraction])) { + continue; + } + $candidates = self::PROVIDE_RULES[$abstraction]; + foreach ($candidates as $candidate => $deps) { + [$candidate, $version] = explode(':', $candidate, 2) + [1 => null]; + if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) { + continue; + } + if (isset($allPackages[$candidate]) && (!$isProject || $dev || !isset($devPackages[$candidate]))) { + continue 2; + } + } + foreach (array_intersect_key(self::STICKYNESS_RULES, $candidates) as $candidate => $stickyRule) { + [$stickyName, $stickyVersion] = explode(':', $stickyRule, 2) + [1 => null]; + if (!isset($allPackages[$stickyName]) || $isProject && !$dev && isset($devPackages[$stickyName])) { + continue; + } + if (null !== $stickyVersion && !$repo->findPackage($stickyName, $versionParser->parseConstraints($stickyVersion))) { + continue; + } + $candidates = [$candidate => $candidates[$candidate]]; + break; + } + $dep = key($candidates); + [$dep] = explode(':', $dep, 2); + $missingRequires[$dev][$abstraction] = [$dep]; + if ($isProject && !$dev && isset($devPackages[$dep])) { + $missingRequires[2][$abstraction][] = $dep; + } + } + } + $missingRequires[1] = array_diff_key($missingRequires[1], $missingRequires[0]); + return $missingRequires; + } + public function preAutoloadDump(Event $event) + { + $filesystem = new Filesystem(); + // Double realpath() on purpose, see https://bugs.php.net/72738 + $vendorDir = $filesystem->normalizePath(realpath(realpath($event->getComposer()->getConfig()->get('vendor-dir')))); + $filesystem->ensureDirectoryExists($vendorDir . '/composer'); + $pinned = $event->getComposer()->getPackage()->getExtra()['discovery'] ?? []; + $candidates = []; + $allInterfaces = array_merge(...array_values(self::INTERFACE_MAP)); + foreach ($pinned as $abstraction => $class) { + if (isset(self::INTERFACE_MAP[$abstraction])) { + $interfaces = self::INTERFACE_MAP[$abstraction]; + } elseif (\false !== $k = array_search($abstraction, $allInterfaces, \true)) { + $interfaces = [$allInterfaces[$k]]; + } else { + throw new \UnexpectedValueException(sprintf('Invalid "extra.discovery" pinned in composer.json: "%s" is not one of ["%s"].', $abstraction, implode('", "', array_keys(self::INTERFACE_MAP)))); + } + foreach ($interfaces as $interface) { + $candidates[] = sprintf("case %s: return [['class' => %s]];\n", var_export($interface, \true), var_export($class, \true)); + } + } + $file = $vendorDir . '/composer/GeneratedDiscoveryStrategy.php'; + if (!$candidates) { + if (file_exists($file)) { + unlink($file); + } + return; + } + $candidates = implode(' ', $candidates); + $code = <<getComposer()->getPackage(); + $autoload = $rootPackage->getAutoload(); + $autoload['classmap'][] = $vendorDir . '/composer/GeneratedDiscoveryStrategy.php'; + $rootPackage->setAutoload($autoload); + } + private function updateComposerJson(array $missingRequires, bool $sortPackages) + { + $file = Factory::getComposerFile(); + $contents = file_get_contents($file); + $manipulator = new JsonManipulator($contents); + foreach ($missingRequires as $key => $packages) { + foreach ($packages as $package => $constraint) { + if ('remove' === $key) { + $manipulator->removeSubNode('require-dev', $package); + } else { + $manipulator->addLink($key, $package, $constraint, $sortPackages); + } + } + } + file_put_contents($file, $manipulator->getContents()); + } + private function updateComposerLock(Composer $composer, IOInterface $io) + { + if (\false === $composer->getConfig()->get('lock')) { + return; + } + $lock = substr(Factory::getComposerFile(), 0, -4) . 'lock'; + $composerJson = file_get_contents(Factory::getComposerFile()); + $lockFile = new JsonFile($lock, null, $io); + $locker = ClassDiscovery::safeClassExists(RepositorySet::class) ? new Locker($io, $lockFile, $composer->getInstallationManager(), $composerJson) : new Locker($io, $lockFile, $composer->getRepositoryManager(), $composer->getInstallationManager(), $composerJson); + if (!$locker->isLocked()) { + return; + } + $lockData = $locker->getLockData(); + $lockData['content-hash'] = Locker::getContentHash($composerJson); + $lockFile->write($lockData); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php new file mode 100644 index 0000000000000..183ac1dbf1f04 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception.php @@ -0,0 +1,12 @@ + + */ +interface Exception extends \Throwable +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php new file mode 100644 index 0000000000000..0dc05d7a5d4d7 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/ClassInstantiationFailedException.php @@ -0,0 +1,13 @@ + + */ +final class ClassInstantiationFailedException extends \RuntimeException implements Exception +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php new file mode 100644 index 0000000000000..f765acddd3fe9 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/DiscoveryFailedException.php @@ -0,0 +1,45 @@ + + */ +final class DiscoveryFailedException extends \Exception implements Exception +{ + /** + * @var \Exception[] + */ + private $exceptions; + /** + * @param string $message + * @param \Exception[] $exceptions + */ + public function __construct($message, array $exceptions = []) + { + $this->exceptions = $exceptions; + parent::__construct($message); + } + /** + * @param \Exception[] $exceptions + */ + public static function create($exceptions) + { + $message = 'Could not find resource using any discovery strategy. Find more information at http://docs.php-http.org/en/latest/discovery.html#common-errors'; + foreach ($exceptions as $e) { + $message .= "\n - " . $e->getMessage(); + } + $message .= "\n\n"; + return new self($message, $exceptions); + } + /** + * @return \Exception[] + */ + public function getExceptions() + { + return $this->exceptions; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php new file mode 100644 index 0000000000000..621d3f708e76e --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NoCandidateFoundException.php @@ -0,0 +1,34 @@ + + */ +final class NoCandidateFoundException extends \Exception implements Exception +{ + /** + * @param string $strategy + */ + public function __construct($strategy, array $candidates) + { + $classes = array_map(function ($a) { + return $a['class']; + }, $candidates); + $message = sprintf('No valid candidate found using strategy "%s". We tested the following candidates: %s.', $strategy, implode(', ', array_map([$this, 'stringify'], $classes))); + parent::__construct($message); + } + private function stringify($mixed) + { + if (is_string($mixed)) { + return $mixed; + } + if (is_array($mixed) && 2 === count($mixed)) { + return sprintf('%s::%s', $this->stringify($mixed[0]), $mixed[1]); + } + return is_object($mixed) ? get_class($mixed) : gettype($mixed); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php new file mode 100644 index 0000000000000..3d93ddf48aaaa --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/NotFoundException.php @@ -0,0 +1,16 @@ + + */ +/* final */ +class NotFoundException extends \RuntimeException implements Exception +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php new file mode 100644 index 0000000000000..0ed157f7a0bbf --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/PuliUnavailableException.php @@ -0,0 +1,12 @@ + + */ +final class PuliUnavailableException extends StrategyUnavailableException +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php new file mode 100644 index 0000000000000..4887391eacd6c --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Exception/StrategyUnavailableException.php @@ -0,0 +1,14 @@ + + */ +class StrategyUnavailableException extends \RuntimeException implements Exception +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php new file mode 100644 index 0000000000000..21b95eb2663fb --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php @@ -0,0 +1,30 @@ + + */ +final class HttpAsyncClientDiscovery extends ClassDiscovery +{ + /** + * Finds an HTTP Async Client. + * + * @return HttpAsyncClient + * + * @throws Exception\NotFoundException + */ + public static function find() + { + try { + $asyncClient = static::findOneByType(HttpAsyncClient::class); + } catch (DiscoveryFailedException $e) { + throw new NotFoundException('No HTTPlug async clients found. Make sure to install a package providing "php-http/async-client-implementation". Example: "php-http/guzzle6-adapter".', 0, $e); + } + return static::instantiateClass($asyncClient); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php new file mode 100644 index 0000000000000..fdfa0ec26edef --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php @@ -0,0 +1,32 @@ + + * + * @deprecated This will be removed in 2.0. Consider using Psr18ClientDiscovery. + */ +final class HttpClientDiscovery extends ClassDiscovery +{ + /** + * Finds an HTTP Client. + * + * @return HttpClient + * + * @throws Exception\NotFoundException + */ + public static function find() + { + try { + $client = static::findOneByType(HttpClient::class); + } catch (DiscoveryFailedException $e) { + throw new NotFoundException('No HTTPlug clients found. Make sure to install a package providing "php-http/client-implementation". Example: "php-http/guzzle6-adapter".', 0, $e); + } + return static::instantiateClass($client); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php new file mode 100644 index 0000000000000..782b61367b00e --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php @@ -0,0 +1,32 @@ + + * + * @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery. + */ +final class MessageFactoryDiscovery extends ClassDiscovery +{ + /** + * Finds a Message Factory. + * + * @return MessageFactory + * + * @throws Exception\NotFoundException + */ + public static function find() + { + try { + $messageFactory = static::findOneByType(MessageFactory::class); + } catch (DiscoveryFailedException $e) { + throw new NotFoundException('No php-http message factories found. Note that the php-http message factories are deprecated in favor of the PSR-17 message factories. To use the legacy Guzzle, Diactoros or Slim Framework factories of php-http, install php-http/message and php-http/message-factory and the chosen message implementation.', 0, $e); + } + return static::instantiateClass($messageFactory); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php new file mode 100644 index 0000000000000..75a7c02f4a74f --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php @@ -0,0 +1,15 @@ + + * + * @deprecated since since version 1.0, and will be removed in 2.0. Use {@link \Http\Discovery\Exception\NotFoundException} instead. + */ +final class NotFoundException extends RealNotFoundException +{ +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php new file mode 100644 index 0000000000000..561f76b0914b8 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php @@ -0,0 +1,241 @@ + + * Copyright (c) 2015 Michael Dowling + * Copyright (c) 2015 Márk Sági-Kazár + * Copyright (c) 2015 Graham Campbell + * Copyright (c) 2016 Tobias Schultze + * Copyright (c) 2016 George Mponos + * Copyright (c) 2016-2018 Tobias Nyholm + * + * @author Nicolas Grekas + */ +class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface +{ + private $requestFactory; + private $responseFactory; + private $serverRequestFactory; + private $streamFactory; + private $uploadedFileFactory; + private $uriFactory; + public function __construct(?RequestFactoryInterface $requestFactory = null, ?ResponseFactoryInterface $responseFactory = null, ?ServerRequestFactoryInterface $serverRequestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?UploadedFileFactoryInterface $uploadedFileFactory = null, ?UriFactoryInterface $uriFactory = null) + { + $this->requestFactory = $requestFactory; + $this->responseFactory = $responseFactory; + $this->serverRequestFactory = $serverRequestFactory; + $this->streamFactory = $streamFactory; + $this->uploadedFileFactory = $uploadedFileFactory; + $this->uriFactory = $uriFactory; + $this->setFactory($requestFactory); + $this->setFactory($responseFactory); + $this->setFactory($serverRequestFactory); + $this->setFactory($streamFactory); + $this->setFactory($uploadedFileFactory); + $this->setFactory($uriFactory); + } + /** + * @param UriInterface|string $uri + */ + public function createRequest(string $method, $uri): RequestInterface + { + $factory = $this->requestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findRequestFactory()); + return $factory->createRequest(...\func_get_args()); + } + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + $factory = $this->responseFactory ?? $this->setFactory(Psr17FactoryDiscovery::findResponseFactory()); + return $factory->createResponse(...\func_get_args()); + } + /** + * @param UriInterface|string $uri + */ + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + $factory = $this->serverRequestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findServerRequestFactory()); + return $factory->createServerRequest(...\func_get_args()); + } + public function createServerRequestFromGlobals(?array $server = null, ?array $get = null, ?array $post = null, ?array $cookie = null, ?array $files = null, ?StreamInterface $body = null): ServerRequestInterface + { + $server = $server ?? $_SERVER; + $request = $this->createServerRequest($server['REQUEST_METHOD'] ?? 'GET', $this->createUriFromGlobals($server), $server); + return $this->buildServerRequestFromGlobals($request, $server, $files ?? $_FILES)->withQueryParams($get ?? $_GET)->withParsedBody($post ?? $_POST)->withCookieParams($cookie ?? $_COOKIE)->withBody($body ?? $this->createStreamFromFile('php://input', 'r+')); + } + public function createStream(string $content = ''): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + return $factory->createStream($content); + } + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + return $factory->createStreamFromFile($filename, $mode); + } + /** + * @param resource $resource + */ + public function createStreamFromResource($resource): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + return $factory->createStreamFromResource($resource); + } + public function createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = \UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface + { + $factory = $this->uploadedFileFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUploadedFileFactory()); + return $factory->createUploadedFile(...\func_get_args()); + } + public function createUri(string $uri = ''): UriInterface + { + $factory = $this->uriFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUriFactory()); + return $factory->createUri(...\func_get_args()); + } + public function createUriFromGlobals(?array $server = null): UriInterface + { + return $this->buildUriFromGlobals($this->createUri(''), $server ?? $_SERVER); + } + private function setFactory($factory) + { + if (!$this->requestFactory && $factory instanceof RequestFactoryInterface) { + $this->requestFactory = $factory; + } + if (!$this->responseFactory && $factory instanceof ResponseFactoryInterface) { + $this->responseFactory = $factory; + } + if (!$this->serverRequestFactory && $factory instanceof ServerRequestFactoryInterface) { + $this->serverRequestFactory = $factory; + } + if (!$this->streamFactory && $factory instanceof StreamFactoryInterface) { + $this->streamFactory = $factory; + } + if (!$this->uploadedFileFactory && $factory instanceof UploadedFileFactoryInterface) { + $this->uploadedFileFactory = $factory; + } + if (!$this->uriFactory && $factory instanceof UriFactoryInterface) { + $this->uriFactory = $factory; + } + return $factory; + } + private function buildServerRequestFromGlobals(ServerRequestInterface $request, array $server, array $files): ServerRequestInterface + { + $request = $request->withProtocolVersion(isset($server['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1')->withUploadedFiles($this->normalizeFiles($files)); + $headers = []; + foreach ($server as $k => $v) { + if (0 === strpos($k, 'HTTP_')) { + $k = substr($k, 5); + } elseif (!\in_array($k, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], \true)) { + continue; + } + $k = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $k)))); + $headers[$k] = $v; + } + if (!isset($headers['Authorization'])) { + if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif (isset($_SERVER['PHP_AUTH_USER'])) { + $headers['Authorization'] = 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . ($_SERVER['PHP_AUTH_PW'] ?? '')); + } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) { + $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST']; + } + } + foreach ($headers as $k => $v) { + try { + $request = $request->withHeader($k, $v); + } catch (\InvalidArgumentException $e) { + // ignore invalid headers + } + } + return $request; + } + private function buildUriFromGlobals(UriInterface $uri, array $server): UriInterface + { + $uri = $uri->withScheme(!empty($server['HTTPS']) && 'off' !== strtolower($server['HTTPS']) ? 'https' : 'http'); + $hasPort = \false; + if (isset($server['HTTP_HOST'])) { + $parts = parse_url('http://' . $server['HTTP_HOST']); + $uri = $uri->withHost($parts['host'] ?? 'localhost'); + if ($parts['port'] ?? \false) { + $hasPort = \true; + $uri = $uri->withPort($parts['port']); + } + } else { + $uri = $uri->withHost($server['SERVER_NAME'] ?? $server['SERVER_ADDR'] ?? 'localhost'); + } + if (!$hasPort && isset($server['SERVER_PORT'])) { + $uri = $uri->withPort($server['SERVER_PORT']); + } + $hasQuery = \false; + if (isset($server['REQUEST_URI'])) { + $requestUriParts = explode('?', $server['REQUEST_URI'], 2); + $uri = $uri->withPath($requestUriParts[0]); + if (isset($requestUriParts[1])) { + $hasQuery = \true; + $uri = $uri->withQuery($requestUriParts[1]); + } + } + if (!$hasQuery && isset($server['QUERY_STRING'])) { + $uri = $uri->withQuery($server['QUERY_STRING']); + } + return $uri; + } + private function normalizeFiles(array $files): array + { + foreach ($files as $k => $v) { + if ($v instanceof UploadedFileInterface) { + continue; + } + if (!\is_array($v)) { + unset($files[$k]); + } elseif (!isset($v['tmp_name'])) { + $files[$k] = $this->normalizeFiles($v); + } else { + $files[$k] = $this->createUploadedFileFromSpec($v); + } + } + return $files; + } + /** + * Create and return an UploadedFile instance from a $_FILES specification. + * + * @param array $value $_FILES struct + * + * @return UploadedFileInterface|UploadedFileInterface[] + */ + private function createUploadedFileFromSpec(array $value) + { + if (!is_array($tmpName = $value['tmp_name'])) { + $file = is_file($tmpName) ? $this->createStreamFromFile($tmpName, 'r') : $this->createStream(); + return $this->createUploadedFile($file, $value['size'], $value['error'], $value['name'], $value['type']); + } + foreach ($tmpName as $k => $v) { + $tmpName[$k] = $this->createUploadedFileFromSpec(['tmp_name' => $v, 'size' => $value['size'][$k] ?? null, 'error' => $value['error'][$k] ?? null, 'name' => $value['name'][$k] ?? null, 'type' => $value['type'][$k] ?? null]); + } + return $tmpName; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php new file mode 100644 index 0000000000000..d9e5f9cd42f27 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php @@ -0,0 +1,119 @@ + + */ +final class Psr17FactoryDiscovery extends ClassDiscovery +{ + private static function createException($type, Exception $e) + { + return new RealNotFoundException('No PSR-17 ' . $type . ' found. Install a package from this list: https://packagist.org/providers/psr/http-factory-implementation', 0, $e); + } + /** + * @return RequestFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findRequestFactory() + { + try { + $messageFactory = static::findOneByType(RequestFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('request factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return ResponseFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findResponseFactory() + { + try { + $messageFactory = static::findOneByType(ResponseFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('response factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return ServerRequestFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findServerRequestFactory() + { + try { + $messageFactory = static::findOneByType(ServerRequestFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('server request factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return StreamFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findStreamFactory() + { + try { + $messageFactory = static::findOneByType(StreamFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('stream factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return UploadedFileFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findUploadedFileFactory() + { + try { + $messageFactory = static::findOneByType(UploadedFileFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('uploaded file factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return UriFactoryInterface + * + * @throws RealNotFoundException + */ + public static function findUriFactory() + { + try { + $messageFactory = static::findOneByType(UriFactoryInterface::class); + } catch (DiscoveryFailedException $e) { + throw self::createException('url factory', $e); + } + return static::instantiateClass($messageFactory); + } + /** + * @return UriFactoryInterface + * + * @throws RealNotFoundException + * + * @deprecated This will be removed in 2.0. Consider using the findUriFactory() method. + */ + public static function findUrlFactory() + { + return static::findUriFactory(); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php new file mode 100644 index 0000000000000..83ed4ce970631 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php @@ -0,0 +1,40 @@ + + */ +class Psr18Client extends Psr17Factory implements ClientInterface +{ + private $client; + public function __construct(?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null, ?ResponseFactoryInterface $responseFactory = null, ?ServerRequestFactoryInterface $serverRequestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?UploadedFileFactoryInterface $uploadedFileFactory = null, ?UriFactoryInterface $uriFactory = null) + { + $requestFactory ?? $requestFactory = $client instanceof RequestFactoryInterface ? $client : null; + $responseFactory ?? $responseFactory = $client instanceof ResponseFactoryInterface ? $client : null; + $serverRequestFactory ?? $serverRequestFactory = $client instanceof ServerRequestFactoryInterface ? $client : null; + $streamFactory ?? $streamFactory = $client instanceof StreamFactoryInterface ? $client : null; + $uploadedFileFactory ?? $uploadedFileFactory = $client instanceof UploadedFileFactoryInterface ? $client : null; + $uriFactory ?? $uriFactory = $client instanceof UriFactoryInterface ? $client : null; + parent::__construct($requestFactory, $responseFactory, $serverRequestFactory, $streamFactory, $uploadedFileFactory, $uriFactory); + $this->client = $client ?? Psr18ClientDiscovery::find(); + } + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->client->sendRequest($request); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php new file mode 100644 index 0000000000000..9093e74df078b --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php @@ -0,0 +1,31 @@ + + */ +final class Psr18ClientDiscovery extends ClassDiscovery +{ + /** + * Finds a PSR-18 HTTP Client. + * + * @return ClientInterface + * + * @throws RealNotFoundException + */ + public static function find() + { + try { + $client = static::findOneByType(ClientInterface::class); + } catch (DiscoveryFailedException $e) { + throw new RealNotFoundException('No PSR-18 clients found. Make sure to install a package providing "psr/http-client-implementation". Example: "php-http/guzzle7-adapter".', 0, $e); + } + return static::instantiateClass($client); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php new file mode 100644 index 0000000000000..02b3fdbf8a5b8 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php @@ -0,0 +1,116 @@ + + * + * Don't miss updating src/Composer/Plugin.php when adding a new supported class. + */ +final class CommonClassesStrategy implements DiscoveryStrategy +{ + /** + * @var array + */ + private static $classes = [MessageFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleMessageFactory::class, 'condition' => [GuzzleRequest::class, GuzzleMessageFactory::class]], ['class' => DiactorosMessageFactory::class, 'condition' => [DiactorosRequest::class, DiactorosMessageFactory::class]], ['class' => SlimMessageFactory::class, 'condition' => [SlimRequest::class, SlimMessageFactory::class]]], StreamFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleStreamFactory::class, 'condition' => [GuzzleRequest::class, GuzzleStreamFactory::class]], ['class' => DiactorosStreamFactory::class, 'condition' => [DiactorosRequest::class, DiactorosStreamFactory::class]], ['class' => SlimStreamFactory::class, 'condition' => [SlimRequest::class, SlimStreamFactory::class]]], UriFactory::class => [['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]], ['class' => GuzzleUriFactory::class, 'condition' => [GuzzleRequest::class, GuzzleUriFactory::class]], ['class' => DiactorosUriFactory::class, 'condition' => [DiactorosRequest::class, DiactorosUriFactory::class]], ['class' => SlimUriFactory::class, 'condition' => [SlimRequest::class, SlimUriFactory::class]]], HttpAsyncClient::class => [['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, Promise::class, [self::class, 'isPsr17FactoryInstalled']]], ['class' => Guzzle7::class, 'condition' => Guzzle7::class], ['class' => Guzzle6::class, 'condition' => Guzzle6::class], ['class' => Curl::class, 'condition' => Curl::class], ['class' => React::class, 'condition' => React::class]], HttpClient::class => [['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, [self::class, 'isPsr17FactoryInstalled'], [self::class, 'isSymfonyImplementingHttpClient']]], ['class' => Guzzle7::class, 'condition' => Guzzle7::class], ['class' => Guzzle6::class, 'condition' => Guzzle6::class], ['class' => Guzzle5::class, 'condition' => Guzzle5::class], ['class' => Curl::class, 'condition' => Curl::class], ['class' => Socket::class, 'condition' => Socket::class], ['class' => Buzz::class, 'condition' => Buzz::class], ['class' => React::class, 'condition' => React::class], ['class' => Cake::class, 'condition' => Cake::class], ['class' => Artax::class, 'condition' => Artax::class], ['class' => [self::class, 'buzzInstantiate'], 'condition' => [\WordPress\AiClientDependencies\Buzz\Client\FileGetContents::class, \WordPress\AiClientDependencies\Buzz\Message\ResponseBuilder::class]]], Psr18Client::class => [['class' => [self::class, 'symfonyPsr18Instantiate'], 'condition' => [SymfonyPsr18::class, Psr17RequestFactory::class]], ['class' => GuzzleHttp::class, 'condition' => [self::class, 'isGuzzleImplementingPsr18']], ['class' => [self::class, 'buzzInstantiate'], 'condition' => [\WordPress\AiClientDependencies\Buzz\Client\FileGetContents::class, \WordPress\AiClientDependencies\Buzz\Message\ResponseBuilder::class]]]]; + public static function getCandidates($type) + { + if (Psr18Client::class === $type) { + return self::getPsr18Candidates(); + } + return self::$classes[$type] ?? []; + } + /** + * @return array The return value is always an array with zero or more elements. Each + * element is an array with two keys ['class' => string, 'condition' => mixed]. + */ + private static function getPsr18Candidates() + { + $candidates = self::$classes[Psr18Client::class]; + // HTTPlug 2.0 clients implements PSR18Client too. + foreach (self::$classes[HttpClient::class] as $c) { + if (!is_string($c['class'])) { + continue; + } + try { + if (ClassDiscovery::safeClassExists($c['class']) && is_subclass_of($c['class'], Psr18Client::class)) { + $candidates[] = $c; + } + } catch (\Throwable $e) { + trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-18 Client is available', get_class($e), $e->getMessage()), \E_USER_WARNING); + } + } + return $candidates; + } + public static function buzzInstantiate() + { + return new \WordPress\AiClientDependencies\Buzz\Client\FileGetContents(Psr17FactoryDiscovery::findResponseFactory()); + } + public static function symfonyPsr18Instantiate() + { + return new SymfonyPsr18(null, Psr17FactoryDiscovery::findResponseFactory(), Psr17FactoryDiscovery::findStreamFactory()); + } + public static function isGuzzleImplementingPsr18() + { + return defined('GuzzleHttp\ClientInterface::MAJOR_VERSION'); + } + public static function isSymfonyImplementingHttpClient() + { + return is_subclass_of(SymfonyHttplug::class, HttpClient::class); + } + /** + * Can be used as a condition. + * + * @return bool + */ + public static function isPsr17FactoryInstalled() + { + try { + Psr17FactoryDiscovery::findResponseFactory(); + } catch (NotFoundException $e) { + return \false; + } catch (\Throwable $e) { + trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-17 ResponseFactory is available', get_class($e), $e->getMessage()), \E_USER_WARNING); + return \false; + } + return \true; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php new file mode 100644 index 0000000000000..3e5227f6d56ce --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php @@ -0,0 +1,34 @@ + + * + * Don't miss updating src/Composer/Plugin.php when adding a new supported class. + */ +final class CommonPsr17ClassesStrategy implements DiscoveryStrategy +{ + /** + * @var array + */ + private static $classes = [RequestFactoryInterface::class => ['Phalcon\Http\Message\RequestFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\RequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\RequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\RequestFactory', 'Laminas\Diactoros\RequestFactory', 'Slim\Psr7\Factory\RequestFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\RequestFactory'], ResponseFactoryInterface::class => ['Phalcon\Http\Message\ResponseFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\ResponseFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\ResponseFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\ResponseFactory', 'Laminas\Diactoros\ResponseFactory', 'Slim\Psr7\Factory\ResponseFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\ResponseFactory'], ServerRequestFactoryInterface::class => ['Phalcon\Http\Message\ServerRequestFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\ServerRequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\ServerRequestFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\ServerRequestFactory', 'Laminas\Diactoros\ServerRequestFactory', 'Slim\Psr7\Factory\ServerRequestFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\ServerRequestFactory'], StreamFactoryInterface::class => ['Phalcon\Http\Message\StreamFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\StreamFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\StreamFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\StreamFactory', 'Laminas\Diactoros\StreamFactory', 'Slim\Psr7\Factory\StreamFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\StreamFactory'], UploadedFileFactoryInterface::class => ['Phalcon\Http\Message\UploadedFileFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\UploadedFileFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\UploadedFileFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\UploadedFileFactory', 'Laminas\Diactoros\UploadedFileFactory', 'Slim\Psr7\Factory\UploadedFileFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\UploadedFileFactory'], UriFactoryInterface::class => ['Phalcon\Http\Message\UriFactory', 'Nyholm\Psr7\Factory\Psr17Factory', 'GuzzleHttp\Psr7\HttpFactory', 'WordPress\AiClientDependencies\Http\Factory\Diactoros\UriFactory', 'WordPress\AiClientDependencies\Http\Factory\Guzzle\UriFactory', 'WordPress\AiClientDependencies\Http\Factory\Slim\UriFactory', 'Laminas\Diactoros\UriFactory', 'Slim\Psr7\Factory\UriFactory', 'WordPress\AiClientDependencies\HttpSoft\Message\UriFactory']]; + public static function getCandidates($type) + { + $candidates = []; + if (isset(self::$classes[$type])) { + foreach (self::$classes[$type] as $class) { + $candidates[] = ['class' => $class, 'condition' => [$class]]; + } + } + return $candidates; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php new file mode 100644 index 0000000000000..d7f782db42df7 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/DiscoveryStrategy.php @@ -0,0 +1,22 @@ + + */ +interface DiscoveryStrategy +{ + /** + * Find a resource of a specific type. + * + * @param string $type + * + * @return array The return value is always an array with zero or more elements. Each + * element is an array with two keys ['class' => string, 'condition' => mixed]. + * + * @throws StrategyUnavailableException if we cannot use this strategy + */ + public static function getCandidates($type); +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php new file mode 100644 index 0000000000000..3c05c3dce8db2 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php @@ -0,0 +1,22 @@ + + */ +final class MockClientStrategy implements DiscoveryStrategy +{ + public static function getCandidates($type) + { + if (is_a(HttpClient::class, $type, \true) || is_a(HttpAsyncClient::class, $type, \true)) { + return [['class' => Mock::class, 'condition' => Mock::class]]; + } + return []; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php new file mode 100644 index 0000000000000..bdcfc82344514 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/PuliBetaStrategy.php @@ -0,0 +1,77 @@ + + * @author Márk Sági-Kazár + */ +class PuliBetaStrategy implements DiscoveryStrategy +{ + /** + * @var GeneratedPuliFactory + */ + protected static $puliFactory; + /** + * @var Discovery + */ + protected static $puliDiscovery; + /** + * @return GeneratedPuliFactory + * + * @throws PuliUnavailableException + */ + private static function getPuliFactory() + { + if (null === self::$puliFactory) { + if (!defined('PULI_FACTORY_CLASS')) { + throw new PuliUnavailableException('Puli Factory is not available'); + } + $puliFactoryClass = PULI_FACTORY_CLASS; + if (!ClassDiscovery::safeClassExists($puliFactoryClass)) { + throw new PuliUnavailableException('Puli Factory class does not exist'); + } + self::$puliFactory = new $puliFactoryClass(); + } + return self::$puliFactory; + } + /** + * Returns the Puli discovery layer. + * + * @return Discovery + * + * @throws PuliUnavailableException + */ + private static function getPuliDiscovery() + { + if (!isset(self::$puliDiscovery)) { + $factory = self::getPuliFactory(); + $repository = $factory->createRepository(); + self::$puliDiscovery = $factory->createDiscovery($repository); + } + return self::$puliDiscovery; + } + public static function getCandidates($type) + { + $returnData = []; + $bindings = self::getPuliDiscovery()->findBindings($type); + foreach ($bindings as $binding) { + $condition = \true; + if ($binding->hasParameterValue('depends')) { + $condition = $binding->getParameterValue('depends'); + } + $returnData[] = ['class' => $binding->getClassName(), 'condition' => $condition]; + } + return $returnData; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php new file mode 100644 index 0000000000000..770dd80b4ae80 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php @@ -0,0 +1,32 @@ + + * + * @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery. + */ +final class StreamFactoryDiscovery extends ClassDiscovery +{ + /** + * Finds a Stream Factory. + * + * @return StreamFactory + * + * @throws Exception\NotFoundException + */ + public static function find() + { + try { + $streamFactory = static::findOneByType(StreamFactory::class); + } catch (DiscoveryFailedException $e) { + throw new NotFoundException('No stream factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e); + } + return static::instantiateClass($streamFactory); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php new file mode 100644 index 0000000000000..8847fa4942c4d --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php @@ -0,0 +1,32 @@ + + * + * @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery. + */ +final class UriFactoryDiscovery extends ClassDiscovery +{ + /** + * Finds a URI Factory. + * + * @return UriFactory + * + * @throws Exception\NotFoundException + */ + public static function find() + { + try { + $uriFactory = static::findOneByType(UriFactory::class); + } catch (DiscoveryFailedException $e) { + throw new NotFoundException('No uri factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e); + } + return static::instantiateClass($uriFactory); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php new file mode 100644 index 0000000000000..663b091a4e57a --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php @@ -0,0 +1,45 @@ + + */ +final class FulfilledPromise implements Promise +{ + /** + * @var mixed + */ + private $result; + /** + * @param mixed $result + */ + public function __construct($result) + { + $this->result = $result; + } + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + if (null === $onFulfilled) { + return $this; + } + try { + return new self($onFulfilled($this->result)); + } catch (\Exception $e) { + return new RejectedPromise($e); + } + } + public function getState() + { + return Promise::FULFILLED; + } + public function wait($unwrap = \true) + { + if ($unwrap) { + return $this->result; + } + return null; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php new file mode 100644 index 0000000000000..8c3dcb452300a --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php @@ -0,0 +1,64 @@ + + * @author Márk Sági-Kazár + */ +interface Promise +{ + /** + * Promise has not been fulfilled or rejected. + */ + const PENDING = 'pending'; + /** + * Promise has been fulfilled. + */ + const FULFILLED = 'fulfilled'; + /** + * Promise has been rejected. + */ + const REJECTED = 'rejected'; + /** + * Adds behavior for when the promise is resolved or rejected (response will be available, or error happens). + * + * If you do not care about one of the cases, you can set the corresponding callable to null + * The callback will be called when the value arrived and never more than once. + * + * @param callable|null $onFulfilled called when a response will be available + * @param callable|null $onRejected called when an exception occurs + * + * @return Promise a new resolved promise with value of the executed callback (onFulfilled / onRejected) + */ + public function then(?callable $onFulfilled = null, ?callable $onRejected = null); + /** + * Returns the state of the promise, one of PENDING, FULFILLED or REJECTED. + * + * @return string + */ + public function getState(); + /** + * Wait for the promise to be fulfilled or rejected. + * + * When this method returns, the request has been resolved and if callables have been + * specified, the appropriate one has terminated. + * + * When $unwrap is true (the default), the response is returned, or the exception thrown + * on failure. Otherwise, nothing is returned or thrown. + * + * @param bool $unwrap Whether to return resolved value / throw reason or not + * + * @return ($unwrap is true ? mixed : null) Resolved value, null if $unwrap is set to false + * + * @throws \Throwable the rejection reason if $unwrap is set to true and the request failed + */ + public function wait($unwrap = \true); +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php new file mode 100644 index 0000000000000..f1d8e2f9a173c --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php @@ -0,0 +1,42 @@ + + */ +final class RejectedPromise implements Promise +{ + /** + * @var \Throwable + */ + private $exception; + public function __construct(\Throwable $exception) + { + $this->exception = $exception; + } + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + if (null === $onRejected) { + return $this; + } + try { + return new FulfilledPromise($onRejected($this->exception)); + } catch (\Exception $e) { + return new self($e); + } + } + public function getState() + { + return Promise::REJECTED; + } + public function wait($unwrap = \true) + { + if ($unwrap) { + throw $this->exception; + } + return null; + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php new file mode 100644 index 0000000000000..d522445fce250 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php @@ -0,0 +1,21 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders(): array; + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader(string $name): bool; + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader(string $name): array; + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine(string $name): string; + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader(string $name, $value): \Psr\Http\Message\MessageInterface; + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader(string $name, $value): \Psr\Http\Message\MessageInterface; + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader(string $name): \Psr\Http\Message\MessageInterface; + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody(): \Psr\Http\Message\StreamInterface; + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(\Psr\Http\Message\StreamInterface $body): \Psr\Http\Message\MessageInterface; +} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php new file mode 100644 index 0000000000000..b06c80eb405b0 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/RequestFactoryInterface.php @@ -0,0 +1,18 @@ +getQuery()` + * or from the `QUERY_STRING` server param. + * + * @return array + */ + public function getQueryParams(): array; + /** + * Return an instance with the specified query string arguments. + * + * These values SHOULD remain immutable over the course of the incoming + * request. They MAY be injected during instantiation, such as from PHP's + * $_GET superglobal, or MAY be derived from some other value such as the + * URI. In cases where the arguments are parsed from the URI, the data + * MUST be compatible with what PHP's parse_str() would return for + * purposes of how duplicate query parameters are handled, and how nested + * sets are handled. + * + * Setting query string arguments MUST NOT change the URI stored by the + * request, nor the values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated query string arguments. + * + * @param array $query Array of query string arguments, typically from + * $_GET. + * @return static + */ + public function withQueryParams(array $query): \Psr\Http\Message\ServerRequestInterface; + /** + * Retrieve normalized file upload data. + * + * This method returns upload metadata in a normalized tree, with each leaf + * an instance of Psr\Http\Message\UploadedFileInterface. + * + * These values MAY be prepared from $_FILES or the message body during + * instantiation, or MAY be injected via withUploadedFiles(). + * + * @return array An array tree of UploadedFileInterface instances; an empty + * array MUST be returned if no data is present. + */ + public function getUploadedFiles(): array; + /** + * Create a new instance with the specified uploaded files. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param array $uploadedFiles An array tree of UploadedFileInterface instances. + * @return static + * @throws \InvalidArgumentException if an invalid structure is provided. + */ + public function withUploadedFiles(array $uploadedFiles): \Psr\Http\Message\ServerRequestInterface; + /** + * Retrieve any parameters provided in the request body. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, this method MUST + * return the contents of $_POST. + * + * Otherwise, this method may return any results of deserializing + * the request body content; as parsing returns structured content, the + * potential types MUST be arrays or objects only. A null value indicates + * the absence of body content. + * + * @return null|array|object The deserialized body parameters, if any. + * These will typically be an array or object. + */ + public function getParsedBody(); + /** + * Return an instance with the specified body parameters. + * + * These MAY be injected during instantiation. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, use this method + * ONLY to inject the contents of $_POST. + * + * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of + * deserializing the request body content. Deserialization/parsing returns + * structured data, and, as such, this method ONLY accepts arrays or objects, + * or a null value if nothing was available to parse. + * + * As an example, if content negotiation determines that the request data + * is a JSON payload, this method could be used to create a request + * instance with the deserialized parameters. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param null|array|object $data The deserialized body data. This will + * typically be in an array or object. + * @return static + * @throws \InvalidArgumentException if an unsupported argument type is + * provided. + */ + public function withParsedBody($data): \Psr\Http\Message\ServerRequestInterface; + /** + * Retrieve attributes derived from the request. + * + * The request "attributes" may be used to allow injection of any + * parameters derived from the request: e.g., the results of path + * match operations; the results of decrypting cookies; the results of + * deserializing non-form-encoded message bodies; etc. Attributes + * will be application and request specific, and CAN be mutable. + * + * @return array Attributes derived from the request. + */ + public function getAttributes(): array; + /** + * Retrieve a single derived request attribute. + * + * Retrieves a single derived request attribute as described in + * getAttributes(). If the attribute has not been previously set, returns + * the default value as provided. + * + * This method obviates the need for a hasAttribute() method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * @return mixed + */ + public function getAttribute(string $name, $default = null); + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return static + */ + public function withAttribute(string $name, $value): \Psr\Http\Message\ServerRequestInterface; + /** + * Return an instance that removes the specified derived request attribute. + * + * This method allows removing a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @return static + */ + public function withoutAttribute(string $name): \Psr\Http\Message\ServerRequestInterface; +} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php new file mode 100644 index 0000000000000..42d3fb70710a9 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/StreamFactoryInterface.php @@ -0,0 +1,43 @@ + + * [user-info@]host[:port] + * + * + * If the port component is not set or is the standard port for the current + * scheme, it SHOULD NOT be included. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.2 + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(): string; + /** + * Retrieve the user information component of the URI. + * + * If no user information is present, this method MUST return an empty + * string. + * + * If a user is present in the URI, this will return that value; + * additionally, if the password is also present, it will be appended to the + * user value, with a colon (":") separating the values. + * + * The trailing "@" character is not part of the user information and MUST + * NOT be added. + * + * @return string The URI user information, in "username[:password]" format. + */ + public function getUserInfo(): string; + /** + * Retrieve the host component of the URI. + * + * If no host is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.2.2. + * + * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 + * @return string The URI host. + */ + public function getHost(): string; + /** + * Retrieve the port component of the URI. + * + * If a port is present, and it is non-standard for the current scheme, + * this method MUST return it as an integer. If the port is the standard port + * used with the current scheme, this method SHOULD return null. + * + * If no port is present, and no scheme is present, this method MUST return + * a null value. + * + * If no port is present, but a scheme is present, this method MAY return + * the standard port for that scheme, but SHOULD return null. + * + * @return null|int The URI port. + */ + public function getPort(): ?int; + /** + * Retrieve the path component of the URI. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * Normally, the empty path "" and absolute path "/" are considered equal as + * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically + * do this normalization because in contexts with a trimmed base path, e.g. + * the front controller, this difference becomes significant. It's the task + * of the user to handle both "" and "/". + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.3. + * + * As an example, if the value should include a slash ("/") not intended as + * delimiter between path segments, that value MUST be passed in encoded + * form (e.g., "%2F") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.3 + * @return string The URI path. + */ + public function getPath(): string; + /** + * Retrieve the query string of the URI. + * + * If no query string is present, this method MUST return an empty string. + * + * The leading "?" character is not part of the query and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.4. + * + * As an example, if a value in a key/value pair of the query string should + * include an ampersand ("&") not intended as a delimiter between values, + * that value MUST be passed in encoded form (e.g., "%26") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.4 + * @return string The URI query string. + */ + public function getQuery(): string; + /** + * Retrieve the fragment component of the URI. + * + * If no fragment is present, this method MUST return an empty string. + * + * The leading "#" character is not part of the fragment and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.5. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.5 + * @return string The URI fragment. + */ + public function getFragment(): string; + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + * @throws \InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme(string $scheme): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified user information. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified user information. + * + * Password is optional, but the user information MUST include the + * user; an empty string for the user is equivalent to removing user + * information. + * + * @param string $user The user name to use for authority. + * @param null|string $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo(string $user, ?string $password = null): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified host. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified host. + * + * An empty host value is equivalent to removing the host. + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + * @throws \InvalidArgumentException for invalid hostnames. + */ + public function withHost(string $host): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified port. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified port. + * + * Implementations MUST raise an exception for ports outside the + * established TCP and UDP port ranges. + * + * A null value provided for the port is equivalent to removing the port + * information. + * + * @param null|int $port The port to use with the new instance; a null value + * removes the port information. + * @return static A new instance with the specified port. + * @throws \InvalidArgumentException for invalid ports. + */ + public function withPort(?int $port): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified path. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified path. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * If the path is intended to be domain-relative rather than path relative then + * it must begin with a slash ("/"). Paths not starting with a slash ("/") + * are assumed to be relative to some base path known to the application or + * consumer. + * + * Users can provide both encoded and decoded path characters. + * Implementations ensure the correct encoding as outlined in getPath(). + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + * @throws \InvalidArgumentException for invalid paths. + */ + public function withPath(string $path): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified query string. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified query string. + * + * Users can provide both encoded and decoded query characters. + * Implementations ensure the correct encoding as outlined in getQuery(). + * + * An empty query string value is equivalent to removing the query string. + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + * @throws \InvalidArgumentException for invalid query strings. + */ + public function withQuery(string $query): \Psr\Http\Message\UriInterface; + /** + * Return an instance with the specified URI fragment. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified URI fragment. + * + * Users can provide both encoded and decoded fragment characters. + * Implementations ensure the correct encoding as outlined in getFragment(). + * + * An empty fragment value is equivalent to removing the fragment. + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment(string $fragment): \Psr\Http\Message\UriInterface; + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString(): string; +} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php new file mode 100644 index 0000000000000..eba53815c0c98 --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php @@ -0,0 +1,10 @@ + value pairs. Cache keys that do not exist or are stale will have $default as value. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. + */ + public function getMultiple($keys, $default = null); + /** + * Persists a set of key => value pairs in the cache, with an optional TTL. + * + * @param iterable $values A list of key => value pairs for a multiple-set operation. + * @param null|int|\DateInterval $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * + * @return bool True on success and false on failure. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $values is neither an array nor a Traversable, + * or if any of the $values are not a legal value. + */ + public function setMultiple($values, $ttl = null); + /** + * Deletes multiple cache items in a single operation. + * + * @param iterable $keys A list of string-based keys to be deleted. + * + * @return bool True if the items were successfully removed. False if there was an error. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. + */ + public function deleteMultiple($keys); + /** + * Determines whether an item is present in the cache. + * + * NOTE: It is recommended that has() is only to be used for cache warming type purposes + * and not to be used within your live applications operations for get/set, as this method + * is subject to a race condition where your has() will return true and immediately after, + * another script can remove it making the state of your app out of date. + * + * @param string $key The cache item key. + * + * @return bool + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + public function has($key); +} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php new file mode 100644 index 0000000000000..7333cb827d27f --- /dev/null +++ b/src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php @@ -0,0 +1,13 @@ + Date: Thu, 5 Feb 2026 18:03:04 -0700 Subject: [PATCH 3/8] feat: adds ai client --- phpunit.xml.dist | 1 + ...wp-ai-client-ability-function-resolver.php | 181 ++ .../class-wp-ai-client-discovery-strategy.php | 90 + .../class-wp-ai-client-event-dispatcher.php | 82 + .../class-wp-ai-client-http-client.php | 229 ++ .../class-wp-ai-client-prompt-builder.php | 370 +++ .../class-wp-ai-client-psr17-factory.php | 115 + .../class-wp-ai-client-psr7-request.php | 384 +++ .../class-wp-ai-client-psr7-response.php | 292 ++ .../class-wp-ai-client-psr7-stream.php | 243 ++ .../ai-client/class-wp-ai-client-psr7-uri.php | 389 +++ src/wp-includes/php-ai-client/autoload.php | 4 +- src/wp-settings.php | 21 + .../includes/wp-ai-client-mock-event.php | 17 + ...wp-ai-client-mock-model-creation-trait.php | 445 +++ .../wpAiClientAbilityFunctionResolver.php | 757 ++++++ .../ai-client/wpAiClientEventDispatcher.php | 55 + .../ai-client/wpAiClientPromptBuilder.php | 2406 +++++++++++++++++ 18 files changed, 6079 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-http-client.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php create mode 100644 tests/phpunit/includes/wp-ai-client-mock-event.php create mode 100644 tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4b6c149867c7d..2ba1cf60023df 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,6 +48,7 @@ src/wp-includes/PHPMailer src/wp-includes/Requests src/wp-includes/php-ai-client + src/wp-includes/ai-client src/wp-includes/SimplePie src/wp-includes/sodium_compat src/wp-includes/Text diff --git a/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php new file mode 100644 index 0000000000000..474314aab498a --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php @@ -0,0 +1,181 @@ +getName(); + if ( null === $name ) { + return false; + } + + return str_starts_with( $name, self::ABILITY_PREFIX ); + } + + /** + * Executes a WordPress ability from a function call. + * + * @since 6.8.0 + * + * @param FunctionCall $call The function call to execute. + * @return FunctionResponse The response from executing the ability. + */ + public static function execute_ability( FunctionCall $call ): FunctionResponse { + $function_name = $call->getName() ?? 'unknown'; + $function_id = $call->getId() ?? 'unknown'; + + if ( ! self::is_ability_call( $call ) ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => 'Not an ability function call', + 'code' => 'invalid_ability_call', + ) + ); + } + + $ability_name = self::function_name_to_ability_name( $function_name ); + $ability = wp_get_ability( $ability_name ); + + if ( ! $ability instanceof WP_Ability ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => sprintf( 'Ability "%s" not found', $ability_name ), + 'code' => 'ability_not_found', + ) + ); + } + + $args = $call->getArgs(); + $result = $ability->execute( ! empty( $args ) ? $args : null ); + + if ( is_wp_error( $result ) ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => $result->get_error_message(), + 'code' => $result->get_error_code(), + 'data' => $result->get_error_data(), + ) + ); + } + + return new FunctionResponse( + $function_id, + $function_name, + $result + ); + } + + /** + * Checks if a message contains any ability function calls. + * + * @since 6.8.0 + * + * @param Message $message The message to check. + * @return bool True if the message contains ability calls, false otherwise. + */ + public static function has_ability_calls( Message $message ): bool { + foreach ( $message->getParts() as $part ) { + if ( $part->getType()->isFunctionCall() ) { + $function_call = $part->getFunctionCall(); + if ( $function_call instanceof FunctionCall && self::is_ability_call( $function_call ) ) { + return true; + } + } + } + + return false; + } + + /** + * Executes all ability function calls in a message. + * + * @since 6.8.0 + * + * @param Message $message The message containing function calls. + * @return Message A new message with function responses. + */ + public static function execute_abilities( Message $message ): Message { + $response_parts = array(); + + foreach ( $message->getParts() as $part ) { + if ( $part->getType()->isFunctionCall() ) { + $function_call = $part->getFunctionCall(); + if ( $function_call instanceof FunctionCall ) { + $function_response = self::execute_ability( $function_call ); + $response_parts[] = new MessagePart( $function_response ); + } + } + } + + return new UserMessage( $response_parts ); + } + + /** + * Converts an ability name to a function name. + * + * Transforms "tec/create_event" to "wpab__tec__create_event". + * + * @since 6.8.0 + * + * @param string $ability_name The ability name to convert. + * @return string The function name. + */ + public static function ability_name_to_function_name( string $ability_name ): string { + return self::ABILITY_PREFIX . str_replace( '/', '__', $ability_name ); + } + + /** + * Converts a function name to an ability name. + * + * Transforms "wpab__tec__create_event" to "tec/create_event". + * + * @since 6.8.0 + * + * @param string $function_name The function name to convert. + * @return string The ability name. + */ + private static function function_name_to_ability_name( string $function_name ): string { + $without_prefix = substr( $function_name, strlen( self::ABILITY_PREFIX ) ); + + return str_replace( '__', '/', $without_prefix ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php new file mode 100644 index 0000000000000..4314609c3a7db --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php @@ -0,0 +1,90 @@ +> List of candidates. + */ + public static function getCandidates( $type ) { + if ( ClientInterface::class === $type ) { + return array( + array( + 'class' => static function () { + return self::create_wordpress_client(); + }, + ), + ); + } + + $psr17_factories = array( + 'Psr\Http\Message\RequestFactoryInterface', + 'Psr\Http\Message\ResponseFactoryInterface', + 'Psr\Http\Message\ServerRequestFactoryInterface', + 'Psr\Http\Message\StreamFactoryInterface', + 'Psr\Http\Message\UploadedFileFactoryInterface', + 'Psr\Http\Message\UriFactoryInterface', + ); + + if ( in_array( $type, $psr17_factories, true ) ) { + return array( + array( + 'class' => WP_AI_Client_PSR17_Factory::class, + ), + ); + } + + return array(); + } + + /** + * Creates an instance of the WordPress HTTP client. + * + * @since 6.8.0 + * + * @return WP_AI_Client_HTTP_Client + */ + private static function create_wordpress_client() { + $psr17_factory = new WP_AI_Client_PSR17_Factory(); + return new WP_AI_Client_HTTP_Client( + $psr17_factory, + $psr17_factory + ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php new file mode 100644 index 0000000000000..bfe294ed1d92f --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php @@ -0,0 +1,82 @@ +get_hook_name_portion_for_event( $event ); + + /** + * Fires when an AI client event is dispatched. + * + * The dynamic portion of the hook name, `$event_name`, refers to the + * snake_case version of the event class name, without the `_event` suffix. + * + * For example, an event class named `BeforeGenerateResultEvent` will fire the + * `wp_ai_client_before_generate_result` action hook. + * + * In practice, the available action hook names are: + * + * - wp_ai_client_before_generate_result + * - wp_ai_client_after_generate_result + * + * @since 6.8.0 + * + * @param object $event The event object. + */ + do_action( "wp_ai_client_{$event_name}", $event ); + + return $event; + } + + /** + * Converts an event object class name to a WordPress action hook name portion. + * + * @since 6.8.0 + * + * @param object $event The event object. + * @return string The hook name portion derived from the event class name. + */ + private function get_hook_name_portion_for_event( object $event ): string { + $class_name = get_class( $event ); + $pos = strrpos( $class_name, '\\' ); + $short_name = false !== $pos ? substr( $class_name, $pos + 1 ) : $class_name; + + // Convert PascalCase to snake_case. + $snake_case = strtolower( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $short_name ) ); + + // Strip '_event' suffix if present. + if ( str_ends_with( $snake_case, '_event' ) ) { + $snake_case = (string) substr( $snake_case, 0, -6 ); + } + + return $snake_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client/class-wp-ai-client-http-client.php new file mode 100644 index 0000000000000..a49324f130a47 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-http-client.php @@ -0,0 +1,229 @@ +response_factory = $response_factory; + $this->stream_factory = $stream_factory; + } + + /** + * Sends a PSR-7 request and returns a PSR-7 response. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return ResponseInterface The PSR-7 response. + * + * @throws NetworkException If the WordPress HTTP request fails. + */ + public function sendRequest( RequestInterface $request ): ResponseInterface { + $args = $this->prepare_wp_args( $request ); + $url = (string) $request->getUri(); + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + $message = sprintf( + 'Network error occurred while sending %s request to %s: %s', + $request->getMethod(), + $url, + $response->get_error_message() + ); + + throw new NetworkException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return $this->create_psr_response( $response ); + } + + /** + * Sends a PSR-7 request with transport options and returns a PSR-7 response. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @param RequestOptions $options Transport options for the request. + * @return ResponseInterface The PSR-7 response. + * + * @throws NetworkException If the WordPress HTTP request fails. + */ + public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface { + $args = $this->prepare_wp_args( $request, $options ); + $url = (string) $request->getUri(); + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + $message = sprintf( + 'Network error occurred while sending request to %s: %s', + $url, + $response->get_error_message() + ); + + throw new NetworkException( + $message, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + $response->get_error_code() ? (int) $response->get_error_code() : 0 + ); + } + + return $this->create_psr_response( $response ); + } + + /** + * Prepares WordPress HTTP API arguments from a PSR-7 request. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @param RequestOptions|null $options Optional transport options for the request. + * @return array WordPress HTTP API arguments. + */ + private function prepare_wp_args( RequestInterface $request, ?RequestOptions $options = null ): array { + $args = array( + 'method' => $request->getMethod(), + 'headers' => $this->prepare_headers( $request ), + 'body' => $this->prepare_body( $request ), + 'httpversion' => $request->getProtocolVersion(), + 'blocking' => true, + ); + + if ( null !== $options ) { + if ( null !== $options->getTimeout() ) { + $args['timeout'] = $options->getTimeout(); + } + + if ( null !== $options->getMaxRedirects() ) { + $args['redirection'] = $options->getMaxRedirects(); + } + } + + return $args; + } + + /** + * Prepares headers for WordPress HTTP API. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return array Headers array for WordPress HTTP API. + */ + private function prepare_headers( RequestInterface $request ): array { + $headers = array(); + + foreach ( $request->getHeaders() as $name => $values ) { + if ( strpos( $name, 'X-Stream' ) === 0 ) { + continue; + } + + $headers[ (string) $name ] = implode( ', ', $values ); + } + + return $headers; + } + + /** + * Prepares request body for WordPress HTTP API. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return string|null The request body. + */ + private function prepare_body( RequestInterface $request ): ?string { + $body = $request->getBody(); + + if ( $body->getSize() === 0 ) { + return null; + } + + if ( $body->isSeekable() ) { + $body->rewind(); + } + + return (string) $body; + } + + /** + * Creates a PSR-7 response from a WordPress HTTP response. + * + * @since 6.8.0 + * + * @param array $wp_response WordPress HTTP API response array. + * @return ResponseInterface PSR-7 response. + */ + private function create_psr_response( array $wp_response ): ResponseInterface { + $status_code = wp_remote_retrieve_response_code( $wp_response ); + $reason_phrase = wp_remote_retrieve_response_message( $wp_response ); + $headers = wp_remote_retrieve_headers( $wp_response ); + $body = wp_remote_retrieve_body( $wp_response ); + + $response = $this->response_factory->createResponse( (int) $status_code, $reason_phrase ); + + if ( $headers instanceof WP_HTTP_Requests_Response ) { + $headers = $headers->get_headers(); + } + + if ( is_array( $headers ) || $headers instanceof Traversable ) { + foreach ( $headers as $name => $value ) { + $response = $response->withHeader( $name, $value ); + } + } + + if ( ! empty( $body ) ) { + $stream = $this->stream_factory->createStream( $body ); + $response = $response->withBody( $stream ); + } + + return $response; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php new file mode 100644 index 0000000000000..e34e15e11936f --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -0,0 +1,370 @@ + $schema) Sets the output schema. + * @method self as_output_modalities(ModalityEnum ...$modalities) Sets the output modalities. + * @method self as_output_file_type(FileTypeEnum $fileType) Sets the output file type. + * @method self as_json_response(?array $schema = null) Configures the prompt for JSON response output. + * @method bool|WP_Error is_supported(?CapabilityEnum $capability = null) Checks if the prompt is supported for the given capability. + * @method bool is_supported_for_text_generation() Checks if the prompt is supported for text generation. + * @method bool is_supported_for_image_generation() Checks if the prompt is supported for image generation. + * @method bool is_supported_for_text_to_speech_conversion() Checks if the prompt is supported for text to speech conversion. + * @method bool is_supported_for_video_generation() Checks if the prompt is supported for video generation. + * @method bool is_supported_for_speech_generation() Checks if the prompt is supported for speech generation. + * @method bool is_supported_for_music_generation() Checks if the prompt is supported for music generation. + * @method bool is_supported_for_embedding_generation() Checks if the prompt is supported for embedding generation. + * @method GenerativeAiResult|WP_Error generate_result(?CapabilityEnum $capability = null) Generates a result from the prompt. + * @method GenerativeAiResult|WP_Error generate_text_result() Generates a text result from the prompt. + * @method GenerativeAiResult|WP_Error generate_image_result() Generates an image result from the prompt. + * @method GenerativeAiResult|WP_Error generate_speech_result() Generates a speech result from the prompt. + * @method GenerativeAiResult|WP_Error convert_text_to_speech_result() Converts text to speech and returns the result. + * @method string|WP_Error generate_text() Generates text from the prompt. + * @method list|WP_Error generate_texts(?int $candidateCount = null) Generates multiple text candidates from the prompt. + * @method File|WP_Error generate_image() Generates an image from the prompt. + * @method list|WP_Error generate_images(?int $candidateCount = null) Generates multiple images from the prompt. + * @method File|WP_Error convert_text_to_speech() Converts text to speech. + * @method list|WP_Error convert_text_to_speeches(?int $candidateCount = null) Converts text to multiple speech outputs. + * @method File|WP_Error generate_speech() Generates speech from the prompt. + * @method list|WP_Error generate_speeches(?int $candidateCount = null) Generates multiple speech outputs from the prompt. + */ +class WP_AI_Client_Prompt_Builder { + + /** + * Wrapped prompt builder instance from the PHP AI Client SDK. + * + * @since 6.8.0 + * @var PromptBuilder + */ + private PromptBuilder $builder; + + /** + * WordPress error instance, if any error occurred during method calls. + * + * @since 6.8.0 + * @var WP_Error|null + */ + private ?WP_Error $error = null; + + /** + * List of methods that terminate the fluent interface and return a result. + * + * Structured as a map for faster lookups. + * + * @since 6.8.0 + * @var array + */ + private static array $terminate_methods = array( + 'generate_result' => true, + 'generate_text_result' => true, + 'generate_image_result' => true, + 'generate_speech_result' => true, + 'convert_text_to_speech_result' => true, + 'generate_text' => true, + 'generate_texts' => true, + 'generate_image' => true, + 'generate_images' => true, + 'convert_text_to_speech' => true, + 'convert_text_to_speeches' => true, + 'generate_speech' => true, + 'generate_speeches' => true, + ); + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param mixed $prompt Optional initial prompt content. + */ + public function __construct( ProviderRegistry $registry, $prompt = null ) { + $this->builder = new PromptBuilder( $registry, $prompt ); + + /** + * Filters the default request timeout in seconds for AI Client HTTP requests. + * + * @since 6.8.0 + * + * @param int $default_timeout The default timeout in seconds. + */ + $default_timeout = (int) apply_filters( 'wp_ai_client_default_request_timeout', 30 ); + + $this->builder->usingRequestOptions( + RequestOptions::fromArray( + array( + RequestOptions::KEY_TIMEOUT => $default_timeout, + ) + ) + ); + } + + /** + * Registers WordPress abilities as function declarations for the AI model. + * + * Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix + * naming convention and passes them to the underlying prompt builder. + * + * @since 6.8.0 + * + * @param WP_Ability|string ...$abilities The abilities to register, either as WP_Ability objects or ability name strings. + * @return self The current instance for method chaining. + */ + public function using_abilities( ...$abilities ): self { + $declarations = array(); + + foreach ( $abilities as $ability ) { + if ( is_string( $ability ) ) { + $ability = wp_get_ability( $ability ); + } + + if ( ! $ability instanceof WP_Ability ) { + continue; + } + + $function_name = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $ability->get_name() ); + $input_schema = $ability->get_input_schema(); + + $declarations[] = new FunctionDeclaration( + $function_name, + $ability->get_description(), + ! empty( $input_schema ) ? $input_schema : null + ); + } + + if ( ! empty( $declarations ) ) { + return $this->using_function_declarations( ...$declarations ); + } + + return $this; + } + + /** + * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * + * This allows WordPress developers to use snake_case naming conventions. It catches + * any exceptions thrown, stores them, and returns a WP_Error when a terminate method + * is called. + * + * @since 6.8.0 + * + * @param string $name The method name in snake_case. + * @param array $arguments The method arguments. + * @return mixed The result of the method call. + */ + public function __call( string $name, array $arguments ) { + /* + * If an error occurred in a previous method call, either return the error for terminate methods, + * or return the same instance for other methods to maintain the fluent interface. + */ + if ( null !== $this->error ) { + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + + // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. + if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + /** + * Filters whether to prevent the prompt from being executed. + * + * @since 6.8.0 + * + * @param bool $prevent Whether to prevent the prompt. Default false. + * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). + */ + $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); + + if ( $prevent ) { + // For is_supported* methods, return false. + if ( $this->is_support_check_method( $name ) ) { + return false; + } + + // For generate_* and convert_text_to_speech* methods, create a WP_Error. + $this->error = new WP_Error( + 'prompt_prevented', + 'Prompt execution was prevented by a filter.', + array( + 'exception_class' => 'WP_AI_Client_Prompt_Prevented', + ) + ); + + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + try { + $callable = $this->get_builder_callable( $name ); + $result = $callable( ...$arguments ); + + // If the result is a PromptBuilder, return the current instance to allow method chaining. + if ( $result instanceof PromptBuilder ) { + return $this; + } + + return $result; + } catch ( Exception $e ) { + $this->error = new WP_Error( + 'prompt_builder_error', + $e->getMessage(), + array( + 'exception_class' => get_class( $e ), + ) + ); + + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + /** + * Checks if a method name is a support check method (is_supported*). + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a support check method, false otherwise. + */ + protected function is_support_check_method( string $name ): bool { + return str_starts_with( $name, 'is_supported' ); + } + + /** + * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a generating method, false otherwise. + */ + protected function is_generating_method( string $name ): bool { + return str_starts_with( $name, 'generate_' ) + || str_starts_with( $name, 'convert_text_to_speech' ); + } + + /** + * Checks if a method is a terminating method. + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a terminating method, false otherwise. + */ + private static function is_terminating_method( string $name ): bool { + return isset( self::$terminate_methods[ $name ] ); + } + + /** + * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. + * + * @since 6.8.0 + * + * @param string $name The method name in snake_case. + * @return callable The callable for the specified method. + * + * @throws BadMethodCallException If the method does not exist. + */ + protected function get_builder_callable( string $name ): callable { + $camel_case_name = $this->snake_to_camel_case( $name ); + + if ( ! is_callable( array( $this->builder, $camel_case_name ) ) ) { + throw new BadMethodCallException( + sprintf( + 'Method %s does not exist on %s', + $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + ) + ); + } + + return array( $this->builder, $camel_case_name ); + } + + /** + * Converts snake_case to camelCase. + * + * @since 6.8.0 + * + * @param string $snake_case The snake_case string. + * @return string The camelCase string. + */ + private function snake_to_camel_case( string $snake_case ): string { + $parts = explode( '_', $snake_case ); + + $camel_case = $parts[0]; + $parts_count = count( $parts ); + for ( $i = 1; $i < $parts_count; $i++ ) { + $camel_case .= ucfirst( $parts[ $i ] ); + } + + return $camel_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php new file mode 100644 index 0000000000000..c9a8f75b9e934 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php @@ -0,0 +1,115 @@ +}> + */ + private $headers = array(); + + /** + * Request body. + * + * @since 6.8.0 + * @var StreamInterface + */ + private $body; + + /** + * Explicit request target, if set. + * + * @since 6.8.0 + * @var string|null + */ + private $request_target; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param string $method HTTP method. + * @param string|UriInterface $uri Request URI. + */ + public function __construct( string $method, $uri ) { + $this->method = $method; + $this->uri = is_string( $uri ) ? new WP_AI_Client_PSR7_Uri( $uri ) : $uri; + $this->body = new WP_AI_Client_PSR7_Stream(); + + $host = $this->uri->getHost(); + if ( '' !== $host && ! $this->hasHeader( 'Host' ) ) { + $this->set_header_internal( 'Host', $host ); + } + } + + /** + * Retrieves the HTTP protocol version. + * + * @since 6.8.0 + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion(): string { + return $this->protocol_version; + } + + /** + * Returns an instance with the specified HTTP protocol version. + * + * @since 6.8.0 + * + * @param string $version HTTP protocol version. + * @return static + */ + public function withProtocolVersion( string $version ): self { + $new = clone $this; + $new->protocol_version = $version; + + return $new; + } + + /** + * Retrieves all message header values. + * + * @since 6.8.0 + * + * @return string[][] Associative array of headers. + */ + public function getHeaders(): array { + $result = array(); + + foreach ( $this->headers as $entry ) { + $result[ $entry['name'] ] = $entry['values']; + } + + return $result; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return bool + */ + public function hasHeader( string $name ): bool { + return isset( $this->headers[ strtolower( $name ) ] ); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string[] Header values. + */ + public function getHeader( string $name ): array { + $normalized = strtolower( $name ); + + if ( ! isset( $this->headers[ $normalized ] ) ) { + return array(); + } + + return $this->headers[ $normalized ]['values']; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string + */ + public function getHeaderLine( string $name ): string { + return implode( ', ', $this->getHeader( $name ) ); + } + + /** + * Returns an instance with the provided value replacing the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withHeader( string $name, $value ): self { + $new = clone $this; + $new->set_header_internal( $name, $value ); + + return $new; + } + + /** + * Returns an instance with the specified header appended with the given value. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withAddedHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $values = is_array( $value ) ? $value : array( $value ); + + if ( isset( $new->headers[ $normalized ] ) ) { + $new->headers[ $normalized ]['values'] = array_merge( + $new->headers[ $normalized ]['values'], + $values + ); + } else { + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => $values, + ); + } + + return $new; + } + + /** + * Returns an instance without the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader( string $name ): self { + $new = clone $this; + unset( $new->headers[ strtolower( $name ) ] ); + + return $new; + } + + /** + * Gets the body of the message. + * + * @since 6.8.0 + * + * @return StreamInterface + */ + public function getBody(): StreamInterface { + return $this->body; + } + + /** + * Returns an instance with the specified message body. + * + * @since 6.8.0 + * + * @param StreamInterface $body Body. + * @return static + */ + public function withBody( StreamInterface $body ): self { + $new = clone $this; + $new->body = $body; + + return $new; + } + + /** + * Retrieves the message's request target. + * + * @since 6.8.0 + * + * @return string + */ + public function getRequestTarget(): string { + if ( null !== $this->request_target ) { + return $this->request_target; + } + + $target = $this->uri->getPath(); + + if ( '' === $target ) { + $target = '/'; + } + + $query = $this->uri->getQuery(); + + if ( '' !== $query ) { + $target .= '?' . $query; + } + + return $target; + } + + /** + * Returns an instance with the specific request-target. + * + * @since 6.8.0 + * + * @param string $requestTarget Request target. + * @return static + */ + public function withRequestTarget( string $requestTarget ): self { + $new = clone $this; + $new->request_target = $requestTarget; + + return $new; + } + + /** + * Retrieves the HTTP method of the request. + * + * @since 6.8.0 + * + * @return string + */ + public function getMethod(): string { + return $this->method; + } + + /** + * Returns an instance with the provided HTTP method. + * + * @since 6.8.0 + * + * @param string $method Case-sensitive method. + * @return static + */ + public function withMethod( string $method ): self { + $new = clone $this; + $new->method = $method; + + return $new; + } + + /** + * Retrieves the URI instance. + * + * @since 6.8.0 + * + * @return UriInterface + */ + public function getUri(): UriInterface { + return $this->uri; + } + + /** + * Returns an instance with the provided URI. + * + * @since 6.8.0 + * + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function withUri( UriInterface $uri, bool $preserveHost = false ): self { + $new = clone $this; + $new->uri = $uri; + + $host = $uri->getHost(); + + if ( ! $preserveHost ) { + if ( '' !== $host ) { + $new->set_header_internal( 'Host', $host ); + } + } elseif ( '' !== $host && ( ! $new->hasHeader( 'Host' ) || '' === $new->getHeaderLine( 'Host' ) ) ) { + $new->set_header_internal( 'Host', $host ); + } + + return $new; + } + + /** + * Sets a header internally (mutating, for use in constructor and clone methods). + * + * @since 6.8.0 + * + * @param string $name Header name. + * @param string|string[] $value Header value(s). + */ + private function set_header_internal( string $name, $value ): void { + $normalized = strtolower( $name ); + $this->headers[ $normalized ] = array( + 'name' => $name, + 'values' => is_array( $value ) ? $value : array( $value ), + ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php new file mode 100644 index 0000000000000..fe84a7dc5dfd1 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php @@ -0,0 +1,292 @@ +}> + */ + private $headers = array(); + + /** + * Response body. + * + * @since 6.8.0 + * @var StreamInterface + */ + private $body; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param int $status_code HTTP status code. + * @param string $reason_phrase Reason phrase to associate with the status code. + */ + public function __construct( int $status_code = 200, string $reason_phrase = '' ) { + $this->status_code = $status_code; + $this->reason_phrase = $reason_phrase; + $this->body = new WP_AI_Client_PSR7_Stream(); + } + + /** + * Gets the response status code. + * + * @since 6.8.0 + * + * @return int Status code. + */ + public function getStatusCode(): int { + return $this->status_code; + } + + /** + * Returns an instance with the specified status code and reason phrase. + * + * @since 6.8.0 + * + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use. + * @return static + */ + public function withStatus( int $code, string $reasonPhrase = '' ): self { + $new = clone $this; + $new->status_code = $code; + $new->reason_phrase = $reasonPhrase; + + return $new; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * @since 6.8.0 + * + * @return string Reason phrase. + */ + public function getReasonPhrase(): string { + return $this->reason_phrase; + } + + /** + * Retrieves the HTTP protocol version. + * + * @since 6.8.0 + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion(): string { + return $this->protocol_version; + } + + /** + * Returns an instance with the specified HTTP protocol version. + * + * @since 6.8.0 + * + * @param string $version HTTP protocol version. + * @return static + */ + public function withProtocolVersion( string $version ): self { + $new = clone $this; + $new->protocol_version = $version; + + return $new; + } + + /** + * Retrieves all message header values. + * + * @since 6.8.0 + * + * @return string[][] Associative array of headers. + */ + public function getHeaders(): array { + $result = array(); + + foreach ( $this->headers as $entry ) { + $result[ $entry['name'] ] = $entry['values']; + } + + return $result; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return bool + */ + public function hasHeader( string $name ): bool { + return isset( $this->headers[ strtolower( $name ) ] ); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string[] Header values. + */ + public function getHeader( string $name ): array { + $normalized = strtolower( $name ); + + if ( ! isset( $this->headers[ $normalized ] ) ) { + return array(); + } + + return $this->headers[ $normalized ]['values']; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string + */ + public function getHeaderLine( string $name ): string { + return implode( ', ', $this->getHeader( $name ) ); + } + + /** + * Returns an instance with the provided value replacing the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => is_array( $value ) ? $value : array( $value ), + ); + + return $new; + } + + /** + * Returns an instance with the specified header appended with the given value. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withAddedHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $values = is_array( $value ) ? $value : array( $value ); + + if ( isset( $new->headers[ $normalized ] ) ) { + $new->headers[ $normalized ]['values'] = array_merge( + $new->headers[ $normalized ]['values'], + $values + ); + } else { + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => $values, + ); + } + + return $new; + } + + /** + * Returns an instance without the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader( string $name ): self { + $new = clone $this; + unset( $new->headers[ strtolower( $name ) ] ); + + return $new; + } + + /** + * Gets the body of the message. + * + * @since 6.8.0 + * + * @return StreamInterface + */ + public function getBody(): StreamInterface { + return $this->body; + } + + /** + * Returns an instance with the specified message body. + * + * @since 6.8.0 + * + * @param StreamInterface $body Body. + * @return static + */ + public function withBody( StreamInterface $body ): self { + $new = clone $this; + $new->body = $body; + + return $new; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php new file mode 100644 index 0000000000000..273b04a8fb669 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php @@ -0,0 +1,243 @@ +content = $content; + } + + /** + * Reads all data from the stream into a string. + * + * @since 6.8.0 + * + * @return string + */ + public function __toString(): string { + return $this->content; + } + + /** + * Closes the stream. No-op for string-backed streams. + * + * @since 6.8.0 + */ + public function close(): void { + // No-op. + } + + /** + * Separates any underlying resources from the stream. + * + * @since 6.8.0 + * + * @return resource|null Always null for string-backed streams. + */ + public function detach() { + return null; + } + + /** + * Gets the size of the stream. + * + * @since 6.8.0 + * + * @return int|null The size in bytes. + */ + public function getSize(): ?int { + return strlen( $this->content ); + } + + /** + * Returns the current position of the read/write pointer. + * + * @since 6.8.0 + * + * @return int Position of the pointer. + */ + public function tell(): int { + return $this->offset; + } + + /** + * Returns true if the stream is at the end. + * + * @since 6.8.0 + * + * @return bool + */ + public function eof(): bool { + return $this->offset >= strlen( $this->content ); + } + + /** + * Returns whether the stream is seekable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isSeekable(): bool { + return true; + } + + /** + * Seeks to a position in the stream. + * + * @since 6.8.0 + * + * @param int $offset Stream offset. + * @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END. + */ + public function seek( int $offset, int $whence = SEEK_SET ): void { + $length = strlen( $this->content ); + + switch ( $whence ) { + case SEEK_SET: + $this->offset = $offset; + break; + case SEEK_CUR: + $this->offset += $offset; + break; + case SEEK_END: + $this->offset = $length + $offset; + break; + } + + if ( $this->offset < 0 ) { + $this->offset = 0; + } + } + + /** + * Seeks to the beginning of the stream. + * + * @since 6.8.0 + */ + public function rewind(): void { + $this->offset = 0; + } + + /** + * Returns whether the stream is writable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isWritable(): bool { + return true; + } + + /** + * Writes data to the stream. + * + * @since 6.8.0 + * + * @param string $string The string to write. + * @return int Number of bytes written. + */ + public function write( string $string ): int { + $this->content .= $string; + $length = strlen( $string ); + $this->offset += $length; + + return $length; + } + + /** + * Returns whether the stream is readable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isReadable(): bool { + return true; + } + + /** + * Reads data from the stream. + * + * @since 6.8.0 + * + * @param int $length Number of bytes to read. + * @return string Data read from the stream. + */ + public function read( int $length ): string { + $data = substr( $this->content, $this->offset, $length ); + $this->offset += strlen( $data ); + + return $data; + } + + /** + * Returns the remaining contents of the stream. + * + * @since 6.8.0 + * + * @return string + */ + public function getContents(): string { + $remaining = substr( $this->content, $this->offset ); + $this->offset = strlen( $this->content ); + + return $remaining; + } + + /** + * Gets stream metadata. + * + * @since 6.8.0 + * + * @param string|null $key Specific metadata to retrieve. + * @return array|mixed|null Returns null for specific keys, empty array otherwise. + */ + public function getMetadata( ?string $key = null ) { + if ( null !== $key ) { + return null; + } + + return array(); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php new file mode 100644 index 0000000000000..8ea0cf4546b7a --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php @@ -0,0 +1,389 @@ + + */ + private static $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + + /** + * URI scheme (e.g. "http", "https"). + * + * @since 6.8.0 + * @var string + */ + private $scheme = ''; + + /** + * URI user info (e.g. "user:password"). + * + * @since 6.8.0 + * @var string + */ + private $user_info = ''; + + /** + * URI host. + * + * @since 6.8.0 + * @var string + */ + private $host = ''; + + /** + * URI port. + * + * @since 6.8.0 + * @var int|null + */ + private $port; + + /** + * URI path. + * + * @since 6.8.0 + * @var string + */ + private $path = ''; + + /** + * URI query string. + * + * @since 6.8.0 + * @var string + */ + private $query = ''; + + /** + * URI fragment. + * + * @since 6.8.0 + * @var string + */ + private $fragment = ''; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param string $uri URI string to parse. + */ + public function __construct( string $uri = '' ) { + if ( '' !== $uri ) { + $parts = wp_parse_url( $uri ); + + if ( false !== $parts ) { + $this->scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; + $this->host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; + $this->port = isset( $parts['port'] ) ? (int) $parts['port'] : null; + $this->path = $parts['path'] ?? ''; + $this->query = $parts['query'] ?? ''; + + $this->fragment = $parts['fragment'] ?? ''; + + if ( isset( $parts['user'] ) ) { + $this->user_info = $parts['user']; + if ( isset( $parts['pass'] ) ) { + $this->user_info .= ':' . $parts['pass']; + } + } + } + } + } + + /** + * Retrieves the scheme component of the URI. + * + * @since 6.8.0 + * + * @return string The URI scheme. + */ + public function getScheme(): string { + return $this->scheme; + } + + /** + * Retrieves the authority component of the URI. + * + * @since 6.8.0 + * + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(): string { + if ( '' === $this->host ) { + return ''; + } + + $authority = $this->host; + + if ( '' !== $this->user_info ) { + $authority = $this->user_info . '@' . $authority; + } + + if ( null !== $this->port && ! $this->is_standard_port() ) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * Retrieves the user information component of the URI. + * + * @since 6.8.0 + * + * @return string The URI user information. + */ + public function getUserInfo(): string { + return $this->user_info; + } + + /** + * Retrieves the host component of the URI. + * + * @since 6.8.0 + * + * @return string The URI host. + */ + public function getHost(): string { + return $this->host; + } + + /** + * Retrieves the port component of the URI. + * + * @since 6.8.0 + * + * @return int|null The URI port, or null if standard or not set. + */ + public function getPort(): ?int { + if ( $this->is_standard_port() ) { + return null; + } + + return $this->port; + } + + /** + * Retrieves the path component of the URI. + * + * @since 6.8.0 + * + * @return string The URI path. + */ + public function getPath(): string { + return $this->path; + } + + /** + * Retrieves the query string of the URI. + * + * @since 6.8.0 + * + * @return string The URI query string. + */ + public function getQuery(): string { + return $this->query; + } + + /** + * Retrieves the fragment component of the URI. + * + * @since 6.8.0 + * + * @return string The URI fragment. + */ + public function getFragment(): string { + return $this->fragment; + } + + /** + * Returns an instance with the specified scheme. + * + * @since 6.8.0 + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + */ + public function withScheme( string $scheme ): UriInterface { + $new = clone $this; + $new->scheme = strtolower( $scheme ); + + return $new; + } + + /** + * Returns an instance with the specified user information. + * + * @since 6.8.0 + * + * @param string $user The user name to use for authority. + * @param string|null $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo( string $user, ?string $password = null ): UriInterface { + $new = clone $this; + $new->user_info = $user; + + if ( null !== $password && '' !== $password ) { + $new->user_info .= ':' . $password; + } + + return $new; + } + + /** + * Returns an instance with the specified host. + * + * @since 6.8.0 + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + */ + public function withHost( string $host ): UriInterface { + $new = clone $this; + $new->host = strtolower( $host ); + + return $new; + } + + /** + * Returns an instance with the specified port. + * + * @since 6.8.0 + * + * @param int|null $port The port to use with the new instance. + * @return static A new instance with the specified port. + */ + public function withPort( ?int $port ): UriInterface { + $new = clone $this; + $new->port = $port; + + return $new; + } + + /** + * Returns an instance with the specified path. + * + * @since 6.8.0 + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + */ + public function withPath( string $path ): UriInterface { + $new = clone $this; + $new->path = $path; + + return $new; + } + + /** + * Returns an instance with the specified query string. + * + * @since 6.8.0 + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + */ + public function withQuery( string $query ): UriInterface { + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * Returns an instance with the specified URI fragment. + * + * @since 6.8.0 + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment( string $fragment ): UriInterface { + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * Returns the string representation as a URI reference. + * + * @since 6.8.0 + * + * @return string + */ + public function __toString(): string { + $uri = ''; + $authority = $this->getAuthority(); + + if ( '' !== $this->scheme ) { + $uri .= $this->scheme . ':'; + } + + if ( '' !== $authority ) { + $uri .= '//' . $authority; + } + + $path = $this->path; + + if ( '' !== $authority && ( '' === $path || '/' !== $path[0] ) ) { + $path = '/' . $path; + } elseif ( '' === $authority && str_starts_with( $path, '//' ) ) { + $path = '/' . ltrim( $path, '/' ); + } + + $uri .= $path; + + if ( '' !== $this->query ) { + $uri .= '?' . $this->query; + } + + if ( '' !== $this->fragment ) { + $uri .= '#' . $this->fragment; + } + + return $uri; + } + + /** + * Checks whether the current port is the standard port for the scheme. + * + * @since 6.8.0 + * + * @return bool True if port is the standard port for the current scheme. + */ + private function is_standard_port(): bool { + if ( null === $this->port ) { + return false; + } + + return isset( self::$default_ports[ $this->scheme ] ) + && self::$default_ports[ $this->scheme ] === $this->port; + } +} diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php index 89548a78aa737..7cd81ed038277 100644 --- a/src/wp-includes/php-ai-client/autoload.php +++ b/src/wp-includes/php-ai-client/autoload.php @@ -17,7 +17,7 @@ static function ( $class_name ) { // Namespace prefix for the AI client. $client_prefix = 'WordPress\\AiClient\\'; - $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' ) + $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) // Namespace prefix for scoped dependencies. $scoped_prefix = 'WordPress\\AiClientDependencies\\'; @@ -27,7 +27,7 @@ static function ( $class_name ) { $psr_prefixes = array( 'Psr\\Http\\Client\\' => 16, 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 21, + 'Psr\\EventDispatcher\\' => 20, 'Psr\\SimpleCache\\' => 16, ); diff --git a/src/wp-settings.php b/src/wp-settings.php index f7dfd28fbcc93..23153988bee04 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -287,6 +287,27 @@ require ABSPATH . WPINC . '/class-wp-http-requests-response.php'; require ABSPATH . WPINC . '/class-wp-http-requests-hooks.php'; require ABSPATH . WPINC . '/php-ai-client/autoload.php'; + +// WP AI Client - PSR-7 implementations. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-stream.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php'; + +// WP AI Client - HTTP transport and infrastructure. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-http-client.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php'; + +// WP AI Client - Prompt builder. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; + +// WP AI Client - Initialization. +WP_AI_Client_Discovery_Strategy::init(); +WordPress\AiClient\AiClient::setEventDispatcher( new WP_AI_Client_Event_Dispatcher() ); + require ABSPATH . WPINC . '/widgets.php'; require ABSPATH . WPINC . '/class-wp-widget.php'; require ABSPATH . WPINC . '/class-wp-widget-factory.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-event.php b/tests/phpunit/includes/wp-ai-client-mock-event.php new file mode 100644 index 0000000000000..5bef4912db7a7 --- /dev/null +++ b/tests/phpunit/includes/wp-ai-client-mock-event.php @@ -0,0 +1,17 @@ +create_test_text_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + + public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + yield $this->result; + } + }; + } + + /** + * Creates a mock image generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&ImageGenerationModelInterface The mock model. + */ + protected function create_mock_image_generation_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_image_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, ImageGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateImageResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock speech generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&SpeechGenerationModelInterface The mock model. + */ + protected function create_mock_speech_generation_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_speech_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, SpeechGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock text-to-speech conversion model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from conversion. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&TextToSpeechConversionModelInterface The mock model. + */ + protected function create_mock_text_to_speech_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_text_to_speech_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextToSpeechConversionModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function convertTextToSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock text generation model that throws an exception. + * + * @param Exception $exception The exception to throw from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&TextGenerationModelInterface The mock model. + */ + protected function create_mock_text_generation_model_with_exception( + Exception $exception, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_text_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $exception ) implements ModelInterface, TextGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private Exception $exception; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + Exception $exception + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->exception = $exception; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + throw $this->exception; + } + + public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + throw $this->exception; + } + }; + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php new file mode 100644 index 0000000000000..fb5e2fefb9f29 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php @@ -0,0 +1,757 @@ + 'WP AI Client Tests', + 'description' => 'Test abilities for WP AI Client.', + ) + ); + + array_pop( $wp_current_filter ); + + // Simulate the abilities init action. + $wp_current_filter[] = 'wp_abilities_api_init'; + + // Register test abilities. + wp_register_ability( + 'wpaiclienttests/simple', + array( + 'label' => 'Simple Test Ability', + 'description' => 'A simple test ability with no parameters.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return array( 'success' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/with-params', + array( + 'label' => 'Test Ability With Parameters', + 'description' => 'A test ability that accepts parameters.', + 'category' => 'wpaiclienttests', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'description' => 'The title parameter.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( array $input ) { + return array( + 'success' => true, + 'title' => $input['title'], + ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/returns-error', + array( + 'label' => 'Test Ability That Returns Error', + 'description' => 'A test ability that returns a WP_Error.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return new WP_Error( 'test_error', 'This is a test error message.' ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/hyphen-test', + array( + 'label' => 'Test Ability With Hyphens', + 'description' => 'A test ability to verify hyphenated names.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return array( 'hyphenated' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + array_pop( $wp_current_filter ); + } + + /** + * Test that is_ability_call returns true for a valid ability call. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_true_for_valid_ability() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__create_event', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertTrue( $result ); + } + + /** + * Test that is_ability_call returns true for a nested namespace. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_true_for_nested_namespace() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__v1__create_event', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertTrue( $result ); + } + + /** + * Test that is_ability_call returns false for a non-ability call. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_for_non_ability() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that is_ability_call returns false when name is null. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_when_name_is_null() { + $call = new FunctionCall( + 'test-id', + null, + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that is_ability_call returns false for partial prefix. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_for_partial_prefix() { + $call = new FunctionCall( + 'test-id', + 'wpab_single_underscore', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that execute_ability returns error for non-ability call. + * + * @ticket TBD + */ + public function test_execute_ability_returns_error_for_non_ability_call() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'regular_function', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertSame( 'Not an ability function call', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'invalid_ability_call', $data['code'] ); + } + + /** + * Test that execute_ability returns error when ability not found. + * + * @ticket TBD + */ + public function test_execute_ability_returns_error_when_ability_not_found() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__nonexistent__ability', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'not found', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'ability_not_found', $data['code'] ); + } + + /** + * Test that execute_ability handles missing id. + * + * @ticket TBD + */ + public function test_execute_ability_handles_missing_id() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + null, + 'wpab__nonexistent__ability', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'unknown', $response->getId() ); + } + + /** + * Test that has_ability_calls returns true when ability call is present. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_true_when_present() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__create_event', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Here is the result:' ), + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertTrue( $result ); + } + + /** + * Test that has_ability_calls returns false when ability call is not present. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_false_when_not_present() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Here is the result:' ), + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that has_ability_calls returns false for text-only message. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_false_for_text_only() { + $message = new UserMessage( + array( + new MessagePart( 'Just some text' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that has_ability_calls returns true with mixed content. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_true_with_mixed_content() { + $regular_call = new FunctionCall( + 'regular-id', + 'regular_function', + array() + ); + + $ability_call = new FunctionCall( + 'ability-id', + 'wpab__tec__create_event', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Some text' ), + new MessagePart( $regular_call ), + new MessagePart( $ability_call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertTrue( $result ); + } + + /** + * Test that has_ability_calls handles empty message. + * + * @ticket TBD + */ + public function test_has_ability_calls_with_empty_message() { + $message = new ModelMessage( array() ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that execute_abilities handles empty message. + * + * @ticket TBD + */ + public function test_execute_abilities_with_empty_message() { + $message = new ModelMessage( array() ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $this->assertCount( 0, $result->getParts() ); + } + + /** + * Test that execute_abilities handles errors gracefully. + * + * @ticket TBD + */ + public function test_execute_abilities_handles_errors_gracefully() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'error', $data ); + } + + /** + * Test that execute_abilities returns a UserMessage. + * + * @ticket TBD + */ + public function test_execute_abilities_returns_user_message() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + } + + /** + * Test that execute_abilities processes multiple calls. + * + * @ticket TBD + */ + public function test_execute_abilities_processes_multiple_calls() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call1 = new FunctionCall( + 'call-1', + 'wpab__nonexistent__ability1', + array() + ); + + $call2 = new FunctionCall( + 'call-2', + 'wpab__nonexistent__ability2', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call1 ), + new MessagePart( $call2 ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 2, $parts ); + } + + /** + * Test that execute_abilities only processes function calls. + * + * @ticket TBD + */ + public function test_execute_abilities_only_processes_function_calls() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Some text' ), + new MessagePart( $call ), + new MessagePart( 'More text' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + // Only the function call should be processed. + $this->assertCount( 1, $parts ); + } + + /** + * Test ability_name_to_function_name with simple name. + * + * @ticket TBD + */ + public function test_ability_name_to_function_name_simple() { + $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/create_event' ); + + $this->assertSame( 'wpab__tec__create_event', $result ); + } + + /** + * Test ability_name_to_function_name with nested namespace. + * + * @ticket TBD + */ + public function test_ability_name_to_function_name_nested() { + $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/v1/create_event' ); + + $this->assertSame( 'wpab__tec__v1__create_event', $result ); + } + + /** + * Test execute_ability with successful execution. + * + * @ticket TBD + */ + public function test_execute_ability_success() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__simple', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + } + + /** + * Test execute_ability with parameters. + * + * @ticket TBD + */ + public function test_execute_ability_with_parameters() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__with-params', + array( 'title' => 'Test Title' ) + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__with-params', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertSame( 'Test Title', $data['title'] ); + } + + /** + * Test execute_ability handles WP_Error. + * + * @ticket TBD + */ + public function test_execute_ability_handles_wp_error() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__returns-error', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__returns-error', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertSame( 'This is a test error message.', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'test_error', $data['code'] ); + } + + /** + * Test execute_abilities with successful execution. + * + * @ticket TBD + */ + public function test_execute_abilities_success() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + } + + /** + * Test execute_abilities with multiple successful executions. + * + * @ticket TBD + */ + public function test_execute_abilities_multiple_success() { + $call1 = new FunctionCall( + 'call-1', + 'wpab__wpaiclienttests__simple', + array() + ); + + $call2 = new FunctionCall( + 'call-2', + 'wpab__wpaiclienttests__hyphen-test', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call1 ), + new MessagePart( $call2 ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 2, $parts ); + + // Check first response. + $response1 = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response1 ); + $data1 = $response1->getResponse(); + $this->assertArrayHasKey( 'success', $data1 ); + $this->assertTrue( $data1['success'] ); + + // Check second response. + $response2 = $parts[1]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response2 ); + $data2 = $response2->getResponse(); + $this->assertArrayHasKey( 'hyphenated', $data2 ); + $this->assertTrue( $data2['hyphenated'] ); + } + + /** + * Test execute_abilities with mixed text and ability calls. + * + * @ticket TBD + */ + public function test_execute_abilities_with_mixed_content() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Starting execution' ), + new MessagePart( $call ), + new MessagePart( 'Execution complete' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + // Only function calls should be processed. + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + } + + /** + * Test execute_abilities with ability that has parameters. + * + * @ticket TBD + */ + public function test_execute_abilities_with_parameters() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__with-params', + array( 'title' => 'Integration Test' ) + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertSame( 'Integration Test', $data['title'] ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php new file mode 100644 index 0000000000000..6e7c7aac40953 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php @@ -0,0 +1,55 @@ +dispatch( $event ); + + $this->assertTrue( $hook_fired, 'The action hook should have been fired' ); + $this->assertSame( $event, $fired_event, 'The fired event should be the same as the dispatched event' ); + $this->assertSame( $event, $result, 'The dispatch method should return the same event' ); + } + + /** + * Test that dispatch returns event without listeners. + * + * @ticket TBD + */ + public function test_dispatch_returns_event_without_listeners() { + $dispatcher = new WP_AI_Client_Event_Dispatcher(); + $event = new stdClass(); + $event->test_value = 'original'; + + $result = $dispatcher->dispatch( $event ); + + $this->assertSame( $event, $result, 'The dispatch method should return the same object' ); + $this->assertSame( 'original', $result->test_value, 'The event object should remain unchanged' ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php new file mode 100644 index 0000000000000..b44417bae77b3 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -0,0 +1,2406 @@ +getProperty( 'builder' ); + $builder_property->setAccessible( true ); + + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $the_property = $reflection_class2->getProperty( $property ); + $the_property->setAccessible( true ); + + return $the_property->getValue( $wrapped_builder ); + } + + /** + * Gets the function declarations from the builder's model config. + * + * @param WP_AI_Client_Prompt_Builder $builder The builder to get declarations from. + * @return list|null The function declarations or null if not set. + */ + private function get_function_declarations( WP_AI_Client_Prompt_Builder $builder ): ?array { + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + return $config->getFunctionDeclarations(); + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + + $this->registry = $this->createMock( ProviderRegistry::class ); + } + + /** + * Test that WP_AI_Client_Prompt_Builder can be instantiated. + * + * @ticket TBD + */ + public function test_instantiation() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder ); + + // Verify the wrapped builder is a PromptBuilder instance. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $prompt_builder ); + + $this->assertInstanceOf( PromptBuilder::class, $wrapped_builder ); + } + + /** + * Test that WP_AI_Client_Prompt_Builder can be instantiated with initial prompt content. + * + * @ticket TBD + */ + public function test_instantiation_with_prompt() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Initial prompt text' ); + + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder ); + } + + /** + * Test that the constructor sets the default request timeout. + * + * @ticket TBD + */ + public function test_constructor_sets_default_request_timeout() { + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertEquals( 30, $request_options->getTimeout() ); + } + + /** + * Test that the constructor allows overriding the default request timeout. + * + * @ticket TBD + */ + public function test_constructor_allows_overriding_request_timeout() { + add_filter( + 'wp_ai_client_default_request_timeout', + static function () { + return 45; + } + ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertEquals( 45, $request_options->getTimeout() ); + } + + /** + * Test method chaining with fluent methods. + * + * @ticket TBD + */ + public function test_method_chaining_returns_decorator() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder->with_text( 'Test text' ); + $this->assertSame( $prompt_builder, $result, 'with_text should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + + $result = $prompt_builder->using_system_instruction( 'System instruction' ); + $this->assertSame( $prompt_builder, $result, 'using_system_instruction should return the decorator instance' ); + + $result = $prompt_builder->using_max_tokens( 100 ); + $this->assertSame( $prompt_builder, $result, 'using_max_tokens should return the decorator instance' ); + + $result = $prompt_builder->using_temperature( 0.7 ); + $this->assertSame( $prompt_builder, $result, 'using_temperature should return the decorator instance' ); + + $result = $prompt_builder->using_top_p( 0.9 ); + $this->assertSame( $prompt_builder, $result, 'using_top_p should return the decorator instance' ); + + $result = $prompt_builder->using_top_k( 50 ); + $this->assertSame( $prompt_builder, $result, 'using_top_k should return the decorator instance' ); + + $result = $prompt_builder->using_presence_penalty( 0.5 ); + $this->assertSame( $prompt_builder, $result, 'using_presence_penalty should return the decorator instance' ); + + $result = $prompt_builder->using_frequency_penalty( 0.5 ); + $this->assertSame( $prompt_builder, $result, 'using_frequency_penalty should return the decorator instance' ); + + $result = $prompt_builder->as_output_mime_type( 'application/json' ); + $this->assertSame( $prompt_builder, $result, 'as_output_mime_type should return the decorator instance' ); + } + + /** + * Test complex method chaining scenario. + * + * @ticket TBD + */ + public function test_complex_method_chaining() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder + ->with_text( 'Test prompt' ) + ->using_system_instruction( 'You are a helpful assistant' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.7 ) + ->using_top_p( 0.9 ); + + $this->assertSame( $prompt_builder, $result, 'Chained methods should return the same decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Test that boolean-returning methods do not return the decorator. + * + * @ticket TBD + */ + public function test_boolean_methods_return_boolean() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Test text' ); + + $result = $prompt_builder->is_supported_for_text_generation(); + $this->assertIsBool( $result, 'is_supported_for_text_generation should return a boolean' ); + $this->assertNotSame( $prompt_builder, $result, 'is_supported_for_text_generation should not return the decorator' ); + } + + /** + * Test snake_case to camelCase conversion. + * + * @ticket TBD + */ + public function test_snake_case_to_camel_case_conversion() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $test_cases = array( + 'with_text' => 'withText', + 'using_system_instruction' => 'usingSystemInstruction', + 'using_max_tokens' => 'usingMaxTokens', + 'as_output_mime_type' => 'asOutputMimeType', + 'using_model_config' => 'usingModelConfig', + 'with_message_parts' => 'withMessageParts', + 'using_stop_sequences' => 'usingStopSequences', + 'using_candidate_count' => 'usingCandidateCount', + 'using_function_declarations' => 'usingFunctionDeclarations', + ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $conversion_method = $reflection_class->getMethod( 'snake_to_camel_case' ); + $conversion_method->setAccessible( true ); + + foreach ( $test_cases as $snake_case => $expected_camel_case ) { + $actual_camel_case = $conversion_method->invoke( $prompt_builder, $snake_case ); + $this->assertSame( $expected_camel_case, $actual_camel_case, "Failed converting {$snake_case} to {$expected_camel_case}" ); + } + } + + /** + * Test that calling a non-existent method returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_invalid_method_returns_wp_error() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Invalid method call stores error but returns $this for chaining. + $result = $prompt_builder->non_existent_method(); + $this->assertSame( $prompt_builder, $result ); + + // Calling a terminate method should return the stored WP_Error. + $result = $prompt_builder->generate_text(); + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'non_existent_method does not exist', $result->get_error_message() ); + } + + /** + * Test that get_builder_callable returns a valid callable. + * + * @ticket TBD + */ + public function test_get_builder_callable() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $callable_method = $reflection_class->getMethod( 'get_builder_callable' ); + $callable_method->setAccessible( true ); + + $callable = $callable_method->invoke( $prompt_builder, 'with_text' ); + $this->assertTrue( is_callable( $callable ), 'get_builder_callable should return a valid callable' ); + + $this->assertIsArray( $callable ); + $this->assertCount( 2, $callable ); + $this->assertInstanceOf( PromptBuilder::class, $callable[0] ); + $this->assertSame( 'withText', $callable[1] ); + } + + /** + * Test that the wrapped builder is properly configured with the registry. + * + * @ticket TBD + */ + public function test_wrapped_builder_has_correct_registry() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $prompt_builder ); + + $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) ); + $registry_property = $wrapped_builder_reflection->getProperty( 'registry' ); + $registry_property->setAccessible( true ); + + $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' ); + } + + /** + * Test method chaining with with_history. + * + * @ticket TBD + */ + public function test_method_chaining_with_history() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $message1 = Message::fromArray( + array( + 'role' => 'user', + 'parts' => array( + array( + 'text' => 'Hello', + ), + ), + ) + ); + $message2 = Message::fromArray( + array( + 'role' => 'user', + 'parts' => array( + array( + 'text' => 'How are you?', + ), + ), + ) + ); + + $result = $prompt_builder->with_history( $message1, $message2 ); + $this->assertSame( $prompt_builder, $result, 'with_history should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Test method chaining with using_model_config. + * + * @ticket TBD + */ + public function test_method_chaining_with_model_config() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $config = new ModelConfig( array( 'maxTokens' => 100 ) ); + + $result = $prompt_builder->using_model_config( $config ); + $this->assertSame( $prompt_builder, $result, 'using_model_config should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Tests constructor with no prompt. + * + * @ticket TBD + */ + public function test_constructor_with_no_prompt() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + $this->assertEmpty( $messages ); + } + + /** + * Tests constructor with string prompt. + * + * @ticket TBD + */ + public function test_constructor_with_string_prompt() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Hello, world!' ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Hello, world!', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests constructor with MessagePart prompt. + * + * @ticket TBD + */ + public function test_constructor_with_message_part_prompt() { + $part = new MessagePart( 'Test message' ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $part ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Test message', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests constructor with Message prompt. + * + * @ticket TBD + */ + public function test_constructor_with_message_prompt() { + $message = new UserMessage( array( new MessagePart( 'User message' ) ) ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertSame( $message, $messages[0] ); + } + + /** + * Tests constructor with list of Messages. + * + * @ticket TBD + */ + public function test_constructor_with_messages_list() { + $messages = array( + new UserMessage( array( new MessagePart( 'First' ) ) ), + new ModelMessage( array( new MessagePart( 'Second' ) ) ), + new UserMessage( array( new MessagePart( 'Third' ) ) ), + ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $messages ); + + /** @var list $actual_messages */ + $actual_messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 3, $actual_messages ); + $this->assertSame( $messages, $actual_messages ); + } + + /** + * Tests constructor with MessageArrayShape. + * + * @ticket TBD + */ + public function test_constructor_with_message_array_shape() { + $message_array = array( + 'role' => 'user', + 'parts' => array( + array( + 'type' => 'text', + 'text' => 'Hello from array', + ), + ), + ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message_array ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Hello from array', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests withText method. + * + * @ticket TBD + */ + public function test_with_text() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_text( 'Some text' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertEquals( 'Some text', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests withText appends to existing user message. + * + * @ticket TBD + */ + public function test_with_text_appends_to_existing_user_message() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Initial text' ); + $builder->with_text( ' Additional text' ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 2, $parts ); + $this->assertEquals( 'Initial text', $parts[0]->getText() ); + $this->assertEquals( ' Additional text', $parts[1]->getText() ); + } + + /** + * Tests withFile method with base64 data. + * + * @ticket TBD + */ + public function test_with_inline_file() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $result = $builder->with_file( $base64, 'image/png' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'data:image/png;base64,' . $base64, $file->getDataUri() ); + $this->assertEquals( 'image/png', $file->getMimeType() ); + } + + /** + * Tests withFile method with remote URL. + * + * @ticket TBD + */ + public function test_with_remote_file() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_file( 'https://example.com/image.jpg', 'image/jpeg' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'https://example.com/image.jpg', $file->getUrl() ); + $this->assertEquals( 'image/jpeg', $file->getMimeType() ); + } + + /** + * Tests withFile with data URI. + * + * @ticket TBD + */ + public function test_with_inline_file_data_uri() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $data_uri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; + $result = $builder->with_file( $data_uri ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'image/jpeg', $file->getMimeType() ); + } + + /** + * Tests withFile with URL without explicit MIME type. + * + * @ticket TBD + */ + public function test_with_remote_file_without_mime_type() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_file( 'https://example.com/audio.mp3' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'https://example.com/audio.mp3', $file->getUrl() ); + $this->assertEquals( 'audio/mpeg', $file->getMimeType() ); + } + + /** + * Tests withFunctionResponse method. + * + * @ticket TBD + */ + public function test_with_function_response() { + $function_response = new FunctionResponse( 'func_id', 'func_name', array( 'result' => 'data' ) ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_function_response( $function_response ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertSame( $function_response, $messages[0]->getParts()[0]->getFunctionResponse() ); + } + + /** + * Tests withMessageParts method. + * + * @ticket TBD + */ + public function test_with_message_parts() { + $part1 = new MessagePart( 'Part 1' ); + $part2 = new MessagePart( 'Part 2' ); + $part3 = new MessagePart( 'Part 3' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_message_parts( $part1, $part2, $part3 ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'Part 1', $parts[0]->getText() ); + $this->assertEquals( 'Part 2', $parts[1]->getText() ); + $this->assertEquals( 'Part 3', $parts[2]->getText() ); + } + + /** + * Tests withHistory method. + * + * @ticket TBD + */ + public function test_with_history() { + $history = array( + new UserMessage( array( new MessagePart( 'User 1' ) ) ), + new ModelMessage( array( new MessagePart( 'Model 1' ) ) ), + new UserMessage( array( new MessagePart( 'User 2' ) ) ), + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_history( ...$history ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 3, $messages ); + $this->assertEquals( 'User 1', $messages[0]->getParts()[0]->getText() ); + $this->assertEquals( 'Model 1', $messages[1]->getParts()[0]->getText() ); + $this->assertEquals( 'User 2', $messages[2]->getParts()[0]->getText() ); + } + + /** + * Tests usingModel method. + * + * @ticket TBD + */ + public function test_using_model() { + $model_config = new ModelConfig(); + $model = $this->createMock( ModelInterface::class ); + $model->method( 'getConfig' )->willReturn( $model_config ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_model( $model ); + + $this->assertSame( $builder, $result ); + + /** @var ModelInterface $actual_model */ + $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' ); + $this->assertSame( $model, $actual_model ); + } + + /** + * Tests constructor with list of string parts. + * + * @ticket TBD + */ + public function test_constructor_with_string_parts_list() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'Part 1', 'Part 2', 'Part 3' ) ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'Part 1', $parts[0]->getText() ); + $this->assertEquals( 'Part 2', $parts[1]->getText() ); + $this->assertEquals( 'Part 3', $parts[2]->getText() ); + } + + /** + * Tests constructor with mixed parts list. + * + * @ticket TBD + */ + public function test_constructor_with_mixed_parts_list() { + $part1 = new MessagePart( 'Part 1' ); + $part2_array = array( + 'type' => 'text', + 'text' => 'Part 2', + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'String part', $part1, $part2_array ) ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'String part', $parts[0]->getText() ); + $this->assertEquals( 'Part 1', $parts[1]->getText() ); + $this->assertEquals( 'Part 2', $parts[2]->getText() ); + } + + /** + * Tests full method chaining. + * + * @ticket TBD + */ + public function test_method_chaining() { + $model = $this->createMock( ModelInterface::class ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder + ->with_text( 'Start of prompt' ) + ->with_file( 'https://example.com/img.jpg', 'image/jpeg' ) + ->using_model( $model ) + ->using_system_instruction( 'Be helpful' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.8 ) + ->using_top_p( 0.95 ) + ->using_top_k( 50 ) + ->using_candidate_count( 2 ) + ->as_json_response(); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + $this->assertCount( 1, $messages ); + $this->assertCount( 2, $messages[0]->getParts() ); + + /** @var ModelInterface $actual_model */ + $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' ); + $this->assertSame( $model, $actual_model ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'Be helpful', $config->getSystemInstruction() ); + $this->assertEquals( 500, $config->getMaxTokens() ); + $this->assertEquals( 0.8, $config->getTemperature() ); + $this->assertEquals( 0.95, $config->getTopP() ); + $this->assertEquals( 50, $config->getTopK() ); + $this->assertEquals( 2, $config->getCandidateCount() ); + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests usingModelPreference skips unavailable model IDs and falls back. + * + * @ticket TBD + */ + public function test_using_model_preference_skips_unavailable_model_id() { + $result = $this->create_test_result( 'Fallback model result' ); + $other_metadata = $this->create_text_model_metadata_with_input_support( 'other-id' ); + $fallback_metadata = $this->create_text_model_metadata_with_input_support( 'fallback-id' ); + $model = $this->create_mock_text_generation_model( $result, $fallback_metadata ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderId' ) + ->with( 'test-provider' ) + ->willReturn( 'test-provider' ); + + $this->registry->expects( $this->once() ) + ->method( 'findProviderModelsMetadataForSupport' ) + ->with( 'test-provider', $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $other_metadata, $fallback_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( 'test-provider', 'fallback-id', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_provider( 'test-provider' ); + $builder->using_model_preference( 'missing-id', 'fallback-id' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference falls back to discovery when no preferences available. + * + * @ticket TBD + */ + public function test_using_model_preference_falls_back_to_discovery() { + $result = $this->create_test_result( 'Discovered model result' ); + $metadata = $this->create_text_model_metadata_with_input_support( 'discovered-id' ); + $provider_metadata = $this->create_test_provider_metadata(); + $provider_models_metadata = new ProviderModelsMetadata( $provider_metadata, array( $metadata ) ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $this->registry->expects( $this->once() ) + ->method( 'findModelsMetadataForSupport' ) + ->with( $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $provider_models_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( $provider_metadata->getId(), 'discovered-id', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findProviderModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model_preference( 'unavailable-model' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference respects priority order when multiple preferred models are available. + * + * @ticket TBD + */ + public function test_using_model_preference_respects_order_when_multiple_available() { + $result = $this->create_test_result( 'Second choice result' ); + $second_choice_metadata = $this->create_text_model_metadata_with_input_support( 'second-choice' ); + $third_choice_metadata = $this->create_text_model_metadata_with_input_support( 'third-choice' ); + $provider_metadata = $this->create_test_provider_metadata(); + + $model = $this->create_mock_text_generation_model( $result, $second_choice_metadata ); + + $provider_models_metadata = new ProviderModelsMetadata( + $provider_metadata, + array( $third_choice_metadata, $second_choice_metadata ) + ); + + $this->registry->expects( $this->once() ) + ->method( 'findModelsMetadataForSupport' ) + ->with( $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $provider_models_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( $provider_metadata->getId(), 'second-choice', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findProviderModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model_preference( 'first-choice', 'second-choice', 'third-choice' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference rejects invalid preference types, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_invalid_type_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( 123 ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preferences must be model identifiers', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects malformed preference tuples, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_invalid_tuple_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( + array( + 'provider' => 'test', + 'model' => 'id', + ) + ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preference tuple must contain model identifier and provider ID.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects empty preference identifiers, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_empty_identifier_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( ' ' ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preference identifiers cannot be empty.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects calls without preferences, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_without_arguments_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference(); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'At least one model preference must be provided.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelConfig method. + * + * @ticket TBD + */ + public function test_using_model_config() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_system_instruction( 'Builder instruction' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.5 ); + + $config = new ModelConfig(); + $config->setSystemInstruction( 'Config instruction' ); + $config->setMaxTokens( 1000 ); + $config->setTopP( 0.9 ); + $config->setTopK( 40 ); + + $result = $builder->using_model_config( $config ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'Builder instruction', $merged_config->getSystemInstruction() ); + $this->assertEquals( 500, $merged_config->getMaxTokens() ); + $this->assertEquals( 0.5, $merged_config->getTemperature() ); + $this->assertEquals( 0.9, $merged_config->getTopP() ); + $this->assertEquals( 40, $merged_config->getTopK() ); + } + + /** + * Tests usingModelConfig with custom options. + * + * @ticket TBD + */ + public function test_using_model_config_with_custom_options() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $config = new ModelConfig(); + $config->setCustomOption( 'stopSequences', array( 'CONFIG_STOP' ) ); + $config->setCustomOption( 'otherOption', 'value' ); + + $builder->using_model_config( $config ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + $custom_options = $merged_config->getCustomOptions(); + + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertIsArray( $custom_options['stopSequences'] ); + $this->assertEquals( array( 'CONFIG_STOP' ), $custom_options['stopSequences'] ); + $this->assertArrayHasKey( 'otherOption', $custom_options ); + $this->assertEquals( 'value', $custom_options['otherOption'] ); + + $builder->using_stop_sequences( 'STOP' ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + $custom_options = $merged_config->getCustomOptions(); + + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertIsArray( $custom_options['stopSequences'] ); + $this->assertEquals( array( 'STOP' ), $custom_options['stopSequences'] ); + $this->assertArrayHasKey( 'otherOption', $custom_options ); + $this->assertEquals( 'value', $custom_options['otherOption'] ); + } + + /** + * Tests usingProvider method. + * + * @ticket TBD + */ + public function test_using_provider() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_provider( 'test-provider' ); + + $this->assertSame( $builder, $result ); + + $actual_provider = $this->get_wrapped_prompt_builder_property_value( $builder, 'providerIdOrClassName' ); + $this->assertEquals( 'test-provider', $actual_provider ); + } + + /** + * Tests usingSystemInstruction method. + * + * @ticket TBD + */ + public function test_using_system_instruction() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_system_instruction( 'You are a helpful assistant.' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'You are a helpful assistant.', $config->getSystemInstruction() ); + } + + /** + * Tests usingMaxTokens method. + * + * @ticket TBD + */ + public function test_using_max_tokens() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_max_tokens( 1000 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 1000, $config->getMaxTokens() ); + } + + /** + * Tests usingTemperature method. + * + * @ticket TBD + */ + public function test_using_temperature() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_temperature( 0.7 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 0.7, $config->getTemperature() ); + } + + /** + * Tests usingTopP method. + * + * @ticket TBD + */ + public function test_using_top_p() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_top_p( 0.9 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 0.9, $config->getTopP() ); + } + + /** + * Tests usingTopK method. + * + * @ticket TBD + */ + public function test_using_top_k() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_top_k( 40 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 40, $config->getTopK() ); + } + + /** + * Tests usingStopSequences method. + * + * @ticket TBD + */ + public function test_using_stop_sequences() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_stop_sequences( 'STOP', 'END', '###' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $custom_options = $config->getCustomOptions(); + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertEquals( array( 'STOP', 'END', '###' ), $custom_options['stopSequences'] ); + } + + /** + * Tests usingCandidateCount method. + * + * @ticket TBD + */ + public function test_using_candidate_count() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_candidate_count( 3 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 3, $config->getCandidateCount() ); + } + + /** + * Tests asOutputMimeType method. + * + * @ticket TBD + */ + public function test_using_output_mime() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_mime_type( 'application/json' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests asOutputSchema method. + * + * @ticket TBD + */ + public function test_using_output_schema() { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + ), + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_schema( $schema ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( $schema, $config->getOutputSchema() ); + } + + /** + * Tests asOutputModalities method. + * + * @ticket TBD + */ + public function test_using_output_modalities() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_modalities( + ModalityEnum::text(), + ModalityEnum::image() + ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertCount( 2, $modalities ); + $this->assertTrue( $modalities[0]->isText() ); + $this->assertTrue( $modalities[1]->isImage() ); + } + + /** + * Tests asJsonResponse method. + * + * @ticket TBD + */ + public function test_as_json_response() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_json_response(); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests asJsonResponse with schema. + * + * @ticket TBD + */ + public function test_as_json_response_with_schema() { + $schema = array( 'type' => 'array' ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_json_response( $schema ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + $this->assertEquals( $schema, $config->getOutputSchema() ); + } + + /** + * Tests validateMessages with empty messages returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_empty_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'Cannot generate from an empty prompt', $result->get_error_message() ); + } + + /** + * Tests validateMessages with non-user first message returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_non_user_first_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( + $this->registry, + array( + new ModelMessage( array( new MessagePart( 'Model says hi' ) ) ), + new UserMessage( array( new MessagePart( 'User response' ) ) ), + ) + ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'The first message must be from a user role', $result->get_error_message() ); + } + + /** + * Tests validateMessages with non-user last message returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_non_user_last_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $builder->with_text( 'Initial user message' ); + + $builder->with_history( + new UserMessage( array( new MessagePart( 'Historical user message' ) ) ), + new ModelMessage( array( new MessagePart( 'Historical model response' ) ) ) + ); + + // Manually add a model message as the last message. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $builder ); + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $messages_property = $reflection_class2->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + + $messages = $messages_property->getValue( $wrapped_builder ); + $messages[] = new ModelMessage( array( new MessagePart( 'Final model message' ) ) ); + $messages_property->setValue( $wrapped_builder, $messages ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'The last message must be from a user role', $result->get_error_message() ); + } + + /** + * Tests parseMessage with empty string returns WP_Error on termination. + * + * The SDK constructor throws immediately for empty strings, so the exception + * is caught in the constructor and stored. + * + * @ticket TBD + */ + public function test_parse_message_empty_string_returns_wp_error() { + // The empty string exception is thrown by the SDK's PromptBuilder constructor, + // which happens before our __call() error handling. We must catch it manually. + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, ' ' ); + // If we get here, the SDK didn't throw. Test would need adjusting. + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Cannot create a message from an empty string', $e->getMessage() ); + } + } + + /** + * Tests parseMessage with empty array returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_parse_message_empty_array_returns_wp_error() { + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array() ); + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Cannot create a message from an empty array', $e->getMessage() ); + } + } + + /** + * Tests parseMessage with invalid type returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_parse_message_invalid_type_returns_wp_error() { + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 123 ); + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Input must be a string, MessagePart, MessagePartArrayShape', $e->getMessage() ); + } + } + + /** + * Tests generateResult with text output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_text_modality() { + $result = $this->createMock( GenerativeAiResult::class ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with image output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_image_modality() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate an image' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::image() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with audio output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_audio_modality() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:audio/wav;base64,UklGRigE=', 'audio/wav' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::audio() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with multimodal output. + * + * @ticket TBD + */ + public function test_generate_result_with_multimodal_output() { + $result = new GenerativeAiResult( + 'test-result', + array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate multimodal' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::text(), ModalityEnum::image() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult returns WP_Error when model does not support modality. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_for_unsupported_modality() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->createMock( ModelInterface::class ); + $model->method( 'metadata' )->willReturn( $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'does not support text generation', $result->get_error_message() ); + } + + /** + * Tests generateResult returns WP_Error for unsupported output modality. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_for_unsupported_output_modality() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->createMock( ModelInterface::class ); + $model->method( 'metadata' )->willReturn( $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::video() ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'Output modality "video" is not yet supported', $result->get_error_message() ); + } + + /** + * Tests generateTextResult method. + * + * @ticket TBD + */ + public function test_generate_text_result() { + $result = new GenerativeAiResult( + 'test-result', + array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text_result(); + $this->assertSame( $result, $actual_result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull( $modalities ); + $this->assertTrue( $modalities[0]->isText() ); + } + + /** + * Tests generateImageResult method. + * + * @ticket TBD + */ + public function test_generate_image_result() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_image_result(); + $this->assertSame( $result, $actual_result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull( $modalities ); + $this->assertTrue( $modalities[0]->isImage() ); + } + + /** + * Tests generateText returns WP_Error when no candidates. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_no_candidates() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model_with_exception( + new RuntimeException( 'No candidates were generated' ), + $metadata + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $result = $builder->generate_text(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'No candidates were generated', $result->get_error_message() ); + } + + /** + * Tests generateText returns WP_Error when message has no parts. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_no_parts() { + $message = new ModelMessage( array() ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateText returns WP_Error when part has no text. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_part_has_no_text() { + $file = new File( 'https://example.com/image.jpg', 'image/jpeg' ); + $message_part = new MessagePart( $file ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateTexts method. + * + * @ticket TBD + */ + public function test_generate_texts() { + $candidates = array( + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 1' ) ) ), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 2' ) ) ), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 3' ) ) ), + FinishReasonEnum::stop() + ), + ); + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' ); + $builder->using_model( $model ); + + $texts = $builder->generate_texts( 3 ); + + $this->assertCount( 3, $texts ); + $this->assertEquals( 'Text 1', $texts[0] ); + $this->assertEquals( 'Text 2', $texts[1] ); + $this->assertEquals( 'Text 3', $texts[2] ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 3, $config->getCandidateCount() ); + } + + /** + * Tests generateTexts returns WP_Error when no text generated. + * + * @ticket TBD + */ + public function test_generate_texts_returns_wp_error_when_no_text_generated() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model_with_exception( + new RuntimeException( 'No text was generated from any candidates' ), + $metadata + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' ); + $builder->using_model( $model ); + + $result = $builder->generate_texts(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'No text was generated from any candidates', $result->get_error_message() ); + } + + /** + * Tests generateImage method. + * + * @ticket TBD + */ + public function test_generate_image() { + $file = new File( 'https://example.com/generated.jpg', 'image/jpeg' ); + $message_part = new MessagePart( $file ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $generated_file = $builder->generate_image(); + $this->assertSame( $file, $generated_file ); + } + + /** + * Tests generateImage returns WP_Error when no image file. + * + * @ticket TBD + */ + public function test_generate_image_returns_wp_error_when_no_file() { + $message_part = new MessagePart( 'Text instead of image' ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_image(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No file content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateImages method. + * + * @ticket TBD + */ + public function test_generate_images() { + $files = array( + new File( 'https://example.com/img1.jpg', 'image/jpeg' ), + new File( 'https://example.com/img2.jpg', 'image/jpeg' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate images' ); + $builder->using_model( $model ); + + $generated_files = $builder->generate_images( 2 ); + + $this->assertCount( 2, $generated_files ); + $this->assertSame( $files[0], $generated_files[0] ); + $this->assertSame( $files[1], $generated_files[1] ); + } + + /** + * Tests convertTextToSpeech method. + * + * @ticket TBD + */ + public function test_convert_text_to_speech() { + $file = new File( 'https://example.com/audio.mp3', 'audio/mp3' ); + $message_part = new MessagePart( $file ); + $message = new Message( MessageRoleEnum::model(), array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_to_speech_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' ); + $builder->using_model( $model ); + + $audio_file = $builder->convert_text_to_speech(); + $this->assertSame( $file, $audio_file ); + } + + /** + * Tests convertTextToSpeeches method. + * + * @ticket TBD + */ + public function test_convert_text_to_speeches() { + $files = array( + new File( 'https://example.com/audio1.mp3', 'audio/mp3' ), + new File( 'https://example.com/audio2.mp3', 'audio/mp3' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_to_speech_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' ); + $builder->using_model( $model ); + + $audio_files = $builder->convert_text_to_speeches( 2 ); + + $this->assertCount( 2, $audio_files ); + $this->assertSame( $files[0], $audio_files[0] ); + $this->assertSame( $files[1], $audio_files[1] ); + } + + /** + * Tests generateSpeech method. + * + * @ticket TBD + */ + public function test_generate_speech() { + $file = new File( 'https://example.com/speech.mp3', 'audio/mp3' ); + $message_part = new MessagePart( $file ); + $message = new Message( MessageRoleEnum::model(), array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + + $speech_file = $builder->generate_speech(); + $this->assertSame( $file, $speech_file ); + } + + /** + * Tests generateSpeeches method. + * + * @ticket TBD + */ + public function test_generate_speeches() { + $files = array( + new File( 'https://example.com/speech1.mp3', 'audio/mp3' ), + new File( 'https://example.com/speech2.mp3', 'audio/mp3' ), + new File( 'https://example.com/speech3.mp3', 'audio/mp3' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop(), + 10 + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + + $speech_files = $builder->generate_speeches( 3 ); + + $this->assertCount( 3, $speech_files ); + $this->assertSame( $files[0], $speech_files[0] ); + $this->assertSame( $files[1], $speech_files[1] ); + $this->assertSame( $files[2], $speech_files[2] ); + } + + /** + * Tests using_abilities with ability name string. + * + * @ticket TBD + */ + public function test_using_ability_with_string() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( 'wpaiclienttests/simple' ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'A simple test ability with no parameters.', $declarations[0]->getDescription() ); + } + + /** + * Tests using_abilities with WP_Ability object. + * + * @ticket TBD + */ + public function test_using_ability_with_wp_ability_object() { + $ability = wp_get_ability( 'wpaiclienttests/with-params' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( $ability ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[0]->getName() ); + $this->assertEquals( 'A test ability that accepts parameters.', $declarations[0]->getDescription() ); + + $params = $declarations[0]->getParameters(); + $this->assertNotNull( $params ); + $this->assertArrayHasKey( 'properties', $params ); + $this->assertArrayHasKey( 'title', $params['properties'] ); + } + + /** + * Tests using_abilities with multiple abilities. + * + * @ticket TBD + */ + public function test_using_ability_with_multiple_abilities() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + 'wpaiclienttests/with-params', + 'wpaiclienttests/returns-error' + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 3, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__returns-error', $declarations[2]->getName() ); + } + + /** + * Tests using_abilities skips non-existent abilities. + * + * @ticket TBD + */ + public function test_using_ability_skips_nonexistent_abilities() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + 'nonexistent/ability', + 'wpaiclienttests/with-params' + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 2, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + } + + /** + * Tests using_abilities with empty arguments returns self. + * + * @ticket TBD + */ + public function test_using_ability_with_no_arguments_returns_self() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities(); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNull( $declarations ); + } + + /** + * Tests using_abilities with mixed strings and WP_Ability objects. + * + * @ticket TBD + */ + public function test_using_ability_with_mixed_types() { + $ability = wp_get_ability( 'wpaiclienttests/with-params' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + $ability + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 2, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + } + + /** + * Tests using_abilities with hyphenated ability name. + * + * @ticket TBD + */ + public function test_using_ability_with_hyphenated_name() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( 'wpaiclienttests/hyphen-test' ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__hyphen-test', $declarations[0]->getName() ); + } + + /** + * Tests using_abilities can be chained with other methods. + * + * @ticket TBD + */ + public function test_using_ability_method_chaining() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder + ->with_text( 'Test prompt' ) + ->using_abilities( 'wpaiclienttests/simple' ) + ->using_system_instruction( 'You are a helpful assistant' ) + ->using_max_tokens( 500 ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'You are a helpful assistant', $config->getSystemInstruction() ); + $this->assertEquals( 500, $config->getMaxTokens() ); + } + + /** + * Tests that is_supported returns false when prevent prompt filter returns true. + * + * @ticket TBD + */ + public function test_is_supported_returns_false_when_filter_prevents_prompt() { + add_filter( 'wp_ai_client_prevent_prompt', '__return_true' ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Tests that generate_result returns WP_Error when prevent prompt filter returns true. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_when_filter_prevents_prompt() { + add_filter( 'wp_ai_client_prevent_prompt', '__return_true' ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_prevented', $result->get_error_code() ); + $this->assertSame( 'Prompt execution was prevented by a filter.', $result->get_error_message() ); + } + + /** + * Tests that prevent prompt filter receives a clone of the builder instance. + * + * @ticket TBD + */ + public function test_prevent_prompt_filter_receives_cloned_builder_instance() { + $captured_builder = null; + + add_filter( + 'wp_ai_client_prevent_prompt', + static function ( $prevent, $builder ) use ( &$captured_builder ) { + $captured_builder = $builder; + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + // Test with is_supported(). + $builder->is_supported(); + $this->assertNotSame( $builder, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder ); + + // Reset and test with generate_result(). + $captured_builder = null; + $builder2 = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + $builder2->generate_result(); + $this->assertNotSame( $builder2, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder ); + } + + /** + * Tests that once in error state, subsequent fluent calls return the same instance. + * + * @ticket TBD + */ + public function test_error_state_fluent_calls_return_same_instance() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Simulate an error state by directly setting the error property. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $error_property = $reflection_class->getProperty( 'error' ); + $error_property->setAccessible( true ); + $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) ); + + $result = $prompt_builder->with_text( 'Test' ); + $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); + + $result = $prompt_builder->using_max_tokens( 100 ); + $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); + } + + /** + * Tests that terminating methods return WP_Error when in error state. + * + * @ticket TBD + */ + public function test_terminating_methods_return_wp_error_in_error_state() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $test_error = new WP_Error( 'test_error', 'Test error message' ); + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $error_property = $reflection_class->getProperty( 'error' ); + $error_property->setAccessible( true ); + $error_property->setValue( $prompt_builder, $test_error ); + + $result = $prompt_builder->generate_text(); + $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' ); + $this->assertSame( $test_error, $result, 'Should return the same WP_Error instance' ); + } + + /** + * Tests that exception in terminating method is caught and returned as WP_Error. + * + * @ticket TBD + */ + public function test_exception_in_terminating_method_caught_and_returned() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $error = $prompt_builder->generate_text(); + + $this->assertWPError( $error, 'generate_text should return WP_Error when exception occurs' ); + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + + $error_data = $error->get_error_data(); + $this->assertIsArray( $error_data ); + $this->assertArrayHasKey( 'exception_class', $error_data ); + $this->assertNotEmpty( $error_data['exception_class'] ); + } + + /** + * Tests that exception in chained method is caught and returned by the terminating method as WP_Error. + * + * @ticket TBD + */ + public function test_exception_in_chained_method_caught_and_returned_by_terminating_method() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder + ->with_text( 'Start of prompt' ) + ->with_file( 'https://example.com/img.jpg', 'image/jpeg' ) + // Invalid: Only provider and model ID must be given. + ->using_model_preference( array( 'test-provider', 'test-model', 'test-version' ) ) + ->using_system_instruction( 'Be helpful' ) + ->generate_text(); + + $this->assertWPError( $result, 'generate_text should return WP_Error when exception occurs' ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertSame( 'Model preference tuple must contain model identifier and provider ID.', $result->get_error_message() ); + + $error_data = $result->get_error_data(); + $this->assertIsArray( $error_data ); + $this->assertArrayHasKey( 'exception_class', $error_data ); + $this->assertNotEmpty( $error_data['exception_class'] ); + } +} From 13b5f4faf18f4f178a2c4788227c3d45072e2dd6 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 13:05:44 -0700 Subject: [PATCH 4/8] refactor: moves prompt builder and renames directory --- phpunit.xml.dist | 2 +- ...wp-ai-client-ability-function-resolver.php | 0 .../class-wp-ai-client-discovery-strategy.php | 0 .../class-wp-ai-client-event-dispatcher.php | 0 .../class-wp-ai-client-http-client.php | 0 .../class-wp-ai-client-psr17-factory.php | 0 .../class-wp-ai-client-psr7-request.php | 0 .../class-wp-ai-client-psr7-response.php | 0 .../class-wp-ai-client-psr7-stream.php | 0 .../class-wp-ai-client-psr7-uri.php | 0 .../class-wp-ai-client-prompt-builder.php | 0 src/wp-settings.php | 22 +++++++++---------- 12 files changed, 12 insertions(+), 12 deletions(-) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-ability-function-resolver.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-discovery-strategy.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-event-dispatcher.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-http-client.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr17-factory.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-request.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-response.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-stream.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-uri.php (100%) rename src/wp-includes/{ai-client => }/class-wp-ai-client-prompt-builder.php (100%) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2ba1cf60023df..fa1b8805a91ec 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,7 +48,7 @@ src/wp-includes/PHPMailer src/wp-includes/Requests src/wp-includes/php-ai-client - src/wp-includes/ai-client + src/wp-includes/ai-client-utils src/wp-includes/SimplePie src/wp-includes/sodium_compat src/wp-includes/Text diff --git a/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-http-client.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php rename to src/wp-includes/class-wp-ai-client-prompt-builder.php diff --git a/src/wp-settings.php b/src/wp-settings.php index 23153988bee04..5b672b6698c92 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -289,20 +289,20 @@ require ABSPATH . WPINC . '/php-ai-client/autoload.php'; // WP AI Client - PSR-7 implementations. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-stream.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-stream.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-uri.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-request.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-response.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr17-factory.php'; // WP AI Client - HTTP transport and infrastructure. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-http-client.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-http-client.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-discovery-strategy.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-event-dispatcher.php'; -// WP AI Client - Prompt builder. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; +// WP AI Client - Abilities and prompt builder. +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/class-wp-ai-client-prompt-builder.php'; // WP AI Client - Initialization. WP_AI_Client_Discovery_Strategy::init(); From 68b268fd3330dc56052d50b0aaaa2c397ee536b1 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 13:15:17 -0700 Subject: [PATCH 5/8] fix: handles support methods in an error state --- .../class-wp-ai-client-prompt-builder.php | 61 +++++++++++-------- .../ai-client/wpAiClientPromptBuilder.php | 36 +++++++---- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index e34e15e11936f..999adeb6e9f90 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -32,11 +32,11 @@ * handling instead of exceptions, snake_case method naming, and integration * with the Abilities API. * - * Only the terminate methods will return a WP_Error, to not break the fluent + * Only the generating methods will return a WP_Error, to not break the fluent * interface. As soon as any exception is caught in a chain of method calls, * the returned instance will be in an error state, and all subsequent method * calls will be no-ops that just return the same error state instance. Only - * when a terminate method is called, the WP_Error will be returned. + * when a generating method is called, the WP_Error will be returned. * * @since 6.8.0 * @@ -108,14 +108,14 @@ class WP_AI_Client_Prompt_Builder { private ?WP_Error $error = null; /** - * List of methods that terminate the fluent interface and return a result. + * List of methods that generate a result from the prompt. * * Structured as a map for faster lookups. * * @since 6.8.0 * @var array */ - private static array $terminate_methods = array( + private static array $generating_methods = array( 'generate_result' => true, 'generate_text_result' => true, 'generate_image_result' => true, @@ -131,6 +131,25 @@ class WP_AI_Client_Prompt_Builder { 'generate_speeches' => true, ); + /** + * List of methods that check whether the prompt is supported. + * + * Structured as a map for faster lookups. + * + * @since 6.8.0 + * @var array + */ + private static array $support_check_methods = array( + 'is_supported' => true, + 'is_supported_for_text_generation' => true, + 'is_supported_for_image_generation' => true, + 'is_supported_for_text_to_speech_conversion' => true, + 'is_supported_for_video_generation' => true, + 'is_supported_for_speech_generation' => true, + 'is_supported_for_music_generation' => true, + 'is_supported_for_embedding_generation' => true, + ); + /** * Constructor. * @@ -219,14 +238,17 @@ public function __call( string $name, array $arguments ) { * or return the same instance for other methods to maintain the fluent interface. */ if ( null !== $this->error ) { - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } + if ( self::is_support_check_method( $name ) ) { + return false; + } return $this; } // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. - if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { /** * Filters whether to prevent the prompt from being executed. * @@ -239,7 +261,7 @@ public function __call( string $name, array $arguments ) { if ( $prevent ) { // For is_supported* methods, return false. - if ( $this->is_support_check_method( $name ) ) { + if ( self::is_support_check_method( $name ) ) { return false; } @@ -252,7 +274,7 @@ public function __call( string $name, array $arguments ) { ) ); - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } return $this; @@ -278,7 +300,7 @@ public function __call( string $name, array $arguments ) { ) ); - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } return $this; @@ -293,8 +315,8 @@ public function __call( string $name, array $arguments ) { * @param string $name The method name. * @return bool True if the method is a support check method, false otherwise. */ - protected function is_support_check_method( string $name ): bool { - return str_starts_with( $name, 'is_supported' ); + private static function is_support_check_method( string $name ): bool { + return isset( self::$support_check_methods[ $name ] ); } /** @@ -305,21 +327,8 @@ protected function is_support_check_method( string $name ): bool { * @param string $name The method name. * @return bool True if the method is a generating method, false otherwise. */ - protected function is_generating_method( string $name ): bool { - return str_starts_with( $name, 'generate_' ) - || str_starts_with( $name, 'convert_text_to_speech' ); - } - - /** - * Checks if a method is a terminating method. - * - * @since 6.8.0 - * - * @param string $name The method name. - * @return bool True if the method is a terminating method, false otherwise. - */ - private static function is_terminating_method( string $name ): bool { - return isset( self::$terminate_methods[ $name ] ); + private static function is_generating_method( string $name ): bool { + return isset( self::$generating_methods[ $name ] ); } /** diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index b44417bae77b3..971c44d02fb4c 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2324,11 +2324,8 @@ public function test_error_state_fluent_calls_return_same_instance() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - // Simulate an error state by directly setting the error property. - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $error_property = $reflection_class->getProperty( 'error' ); - $error_property->setAccessible( true ); - $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) ); + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); $result = $prompt_builder->with_text( 'Test' ); $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); @@ -2338,23 +2335,36 @@ public function test_error_state_fluent_calls_return_same_instance() { } /** - * Tests that terminating methods return WP_Error when in error state. + * Tests that support check methods return false when in error state. * * @ticket TBD */ - public function test_terminating_methods_return_wp_error_in_error_state() { + public function test_support_check_methods_return_false_in_error_state() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - $test_error = new WP_Error( 'test_error', 'Test error message' ); - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $error_property = $reflection_class->getProperty( 'error' ); - $error_property->setAccessible( true ); - $error_property->setValue( $prompt_builder, $test_error ); + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); + + $this->assertFalse( $prompt_builder->is_supported(), 'is_supported should return false when in error state' ); + $this->assertFalse( $prompt_builder->is_supported_for_text_generation(), 'is_supported_for_text_generation should return false when in error state' ); + } + + /** + * Tests that generating methods return WP_Error when in error state. + * + * @ticket TBD + */ + public function test_generating_methods_return_wp_error_in_error_state() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); $result = $prompt_builder->generate_text(); $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' ); - $this->assertSame( $test_error, $result, 'Should return the same WP_Error instance' ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); } /** From 478e953ce2a62233ad5c2fd890dd575ba680a8a2 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 14:28:13 -0700 Subject: [PATCH 6/8] refactor: namespaces PSR classes and corrects versions --- ...wp-ai-client-ability-function-resolver.php | 18 +++--- .../class-wp-ai-client-discovery-strategy.php | 24 ++++---- .../class-wp-ai-client-event-dispatcher.php | 12 ++-- .../class-wp-ai-client-http-client.php | 32 +++++----- .../class-wp-ai-client-psr17-factory.php | 32 +++++----- .../class-wp-ai-client-psr7-request.php | 60 +++++++++---------- .../class-wp-ai-client-psr7-response.php | 48 +++++++-------- .../class-wp-ai-client-psr7-stream.php | 42 ++++++------- .../class-wp-ai-client-psr7-uri.php | 58 +++++++++--------- .../class-wp-ai-client-prompt-builder.php | 30 +++++----- src/wp-includes/php-ai-client/autoload.php | 24 +------- .../php-ai-client/src/AiClient.php | 4 +- .../src/Builders/PromptBuilder.php | 2 +- .../Contracts/ClientWithOptionsInterface.php | 4 +- .../src/Providers/Http/DTO/Request.php | 2 +- .../Http/Exception/NetworkException.php | 2 +- .../src/Providers/Http/HttpTransporter.php | 14 ++--- .../third-party/Http/Client/Exception.php | 2 +- .../Http/Client/Exception/HttpException.php | 4 +- .../Client/Exception/NetworkException.php | 4 +- .../Client/Exception/RequestAwareTrait.php | 2 +- .../Client/Exception/RequestException.php | 4 +- .../Http/Client/HttpAsyncClient.php | 2 +- .../third-party/Http/Client/HttpClient.php | 2 +- .../Client/Promise/HttpFulfilledPromise.php | 2 +- .../Http/Discovery/Composer/Plugin.php | 38 ++++++------ .../Http/Discovery/Psr17Factory.php | 24 ++++---- .../Http/Discovery/Psr17FactoryDiscovery.php | 12 ++-- .../Http/Discovery/Psr18Client.php | 18 +++--- .../Http/Discovery/Psr18ClientDiscovery.php | 2 +- .../Strategy/CommonClassesStrategy.php | 4 +- .../Strategy/CommonPsr17ClassesStrategy.php | 12 ++-- .../EventDispatcherInterface.php | 2 +- .../ListenerProviderInterface.php | 2 +- .../StoppableEventInterface.php | 2 +- .../Http/Client/ClientExceptionInterface.php | 2 +- .../Psr/Http/Client/ClientInterface.php | 6 +- .../Http/Client/NetworkExceptionInterface.php | 6 +- .../Http/Client/RequestExceptionInterface.php | 6 +- .../Psr/Http/Message/MessageInterface.php | 14 ++--- .../Http/Message/RequestFactoryInterface.php | 4 +- .../Psr/Http/Message/RequestInterface.php | 12 ++-- .../Http/Message/ResponseFactoryInterface.php | 4 +- .../Psr/Http/Message/ResponseInterface.php | 6 +- .../Message/ServerRequestFactoryInterface.php | 4 +- .../Http/Message/ServerRequestInterface.php | 16 ++--- .../Http/Message/StreamFactoryInterface.php | 8 +-- .../Psr/Http/Message/StreamInterface.php | 2 +- .../Message/UploadedFileFactoryInterface.php | 4 +- .../Http/Message/UploadedFileInterface.php | 4 +- .../Psr/Http/Message/UriFactoryInterface.php | 4 +- .../Psr/Http/Message/UriInterface.php | 16 ++--- .../Psr/SimpleCache/CacheException.php | 2 +- .../Psr/SimpleCache/CacheInterface.php | 2 +- .../SimpleCache/InvalidArgumentException.php | 4 +- .../includes/wp-ai-client-mock-event.php | 2 +- ...wp-ai-client-mock-model-creation-trait.php | 2 +- tools/php-ai-client/installer.sh | 38 ++++-------- tools/php-ai-client/scoper.inc.php | 18 +----- 59 files changed, 342 insertions(+), 390 deletions(-) diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php index 474314aab498a..e50b86da50165 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClient\Messages\DTO\Message; @@ -16,14 +16,14 @@ /** * Resolves and executes WordPress Abilities API function calls from AI models. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Ability_Function_Resolver { /** * Prefix used to identify ability function calls. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private const ABILITY_PREFIX = 'wpab__'; @@ -31,7 +31,7 @@ class WP_AI_Client_Ability_Function_Resolver { /** * Checks if a function call is an ability call. * - * @since 6.8.0 + * @since 7.0.0 * * @param FunctionCall $call The function call to check. * @return bool True if the function call is an ability call, false otherwise. @@ -48,7 +48,7 @@ public static function is_ability_call( FunctionCall $call ): bool { /** * Executes a WordPress ability from a function call. * - * @since 6.8.0 + * @since 7.0.0 * * @param FunctionCall $call The function call to execute. * @return FunctionResponse The response from executing the ability. @@ -107,7 +107,7 @@ public static function execute_ability( FunctionCall $call ): FunctionResponse { /** * Checks if a message contains any ability function calls. * - * @since 6.8.0 + * @since 7.0.0 * * @param Message $message The message to check. * @return bool True if the message contains ability calls, false otherwise. @@ -128,7 +128,7 @@ public static function has_ability_calls( Message $message ): bool { /** * Executes all ability function calls in a message. * - * @since 6.8.0 + * @since 7.0.0 * * @param Message $message The message containing function calls. * @return Message A new message with function responses. @@ -154,7 +154,7 @@ public static function execute_abilities( Message $message ): Message { * * Transforms "tec/create_event" to "wpab__tec__create_event". * - * @since 6.8.0 + * @since 7.0.0 * * @param string $ability_name The ability name to convert. * @return string The function name. @@ -168,7 +168,7 @@ public static function ability_name_to_function_name( string $ability_name ): st * * Transforms "wpab__tec__create_event" to "tec/create_event". * - * @since 6.8.0 + * @since 7.0.0 * * @param string $function_name The function name to convert. * @return string The ability name. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php index 4314609c3a7db..80bdea4968617 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php @@ -4,12 +4,12 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery; use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * Discovery strategy for WordPress HTTP client. @@ -17,14 +17,14 @@ * Registers the WordPress HTTP client adapter with the HTTPlug discovery system * so the AI Client SDK can find and use it automatically. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Discovery_Strategy implements DiscoveryStrategy { /** * Initializes and registers the discovery strategy. * - * @since 6.8.0 + * @since 7.0.0 */ public static function init() { if ( ! class_exists( '\WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery' ) ) { @@ -37,7 +37,7 @@ public static function init() { /** * Gets candidates for discovery. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $type The type of discovery. * @return array> List of candidates. @@ -54,12 +54,12 @@ public static function getCandidates( $type ) { } $psr17_factories = array( - 'Psr\Http\Message\RequestFactoryInterface', - 'Psr\Http\Message\ResponseFactoryInterface', - 'Psr\Http\Message\ServerRequestFactoryInterface', - 'Psr\Http\Message\StreamFactoryInterface', - 'Psr\Http\Message\UploadedFileFactoryInterface', - 'Psr\Http\Message\UriFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface', ); if ( in_array( $type, $psr17_factories, true ) ) { @@ -76,7 +76,7 @@ public static function getCandidates( $type ) { /** * Creates an instance of the WordPress HTTP client. * - * @since 6.8.0 + * @since 7.0.0 * * @return WP_AI_Client_HTTP_Client */ diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php index bfe294ed1d92f..9eeb85b32a6c0 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php @@ -4,10 +4,10 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; /** * WordPress-specific PSR-14 event dispatcher for the AI Client. @@ -15,7 +15,7 @@ * Bridges PSR-14 events to WordPress action hooks, enabling plugins to hook * into AI client lifecycle events. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface { @@ -25,7 +25,7 @@ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface { * Converts the event class name to a WordPress action hook name and fires it. * For example, BeforeGenerateResultEvent becomes wp_ai_client_before_generate_result. * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object to dispatch. * @return object The same event object, potentially modified by listeners. @@ -47,7 +47,7 @@ public function dispatch( object $event ): object { * - wp_ai_client_before_generate_result * - wp_ai_client_after_generate_result * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object. */ @@ -59,7 +59,7 @@ public function dispatch( object $event ): object { /** * Converts an event object class name to a WordPress action hook name portion. * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object. * @return string The hook name portion derived from the event class name. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php index a49324f130a47..bddcde6cf62c0 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php @@ -4,14 +4,14 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\Exception\NetworkException; @@ -22,14 +22,14 @@ * Allows WordPress HTTP functions to be used as a PSR-18 compliant HTTP client * for the AI Client SDK. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInterface { /** * Response factory instance. * - * @since 6.8.0 + * @since 7.0.0 * @var ResponseFactoryInterface */ private $response_factory; @@ -37,7 +37,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte /** * Stream factory instance. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamFactoryInterface */ private $stream_factory; @@ -45,7 +45,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param ResponseFactoryInterface $response_factory PSR-17 Response factory. * @param StreamFactoryInterface $stream_factory PSR-17 Stream factory. @@ -58,7 +58,7 @@ public function __construct( ResponseFactoryInterface $response_factory, StreamF /** * Sends a PSR-7 request and returns a PSR-7 response. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return ResponseInterface The PSR-7 response. @@ -88,7 +88,7 @@ public function sendRequest( RequestInterface $request ): ResponseInterface { /** * Sends a PSR-7 request with transport options and returns a PSR-7 response. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @param RequestOptions $options Transport options for the request. @@ -121,7 +121,7 @@ public function sendRequestWithOptions( RequestInterface $request, RequestOption /** * Prepares WordPress HTTP API arguments from a PSR-7 request. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @param RequestOptions|null $options Optional transport options for the request. @@ -152,7 +152,7 @@ private function prepare_wp_args( RequestInterface $request, ?RequestOptions $op /** * Prepares headers for WordPress HTTP API. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return array Headers array for WordPress HTTP API. @@ -174,7 +174,7 @@ private function prepare_headers( RequestInterface $request ): array { /** * Prepares request body for WordPress HTTP API. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return string|null The request body. @@ -196,7 +196,7 @@ private function prepare_body( RequestInterface $request ): ?string { /** * Creates a PSR-7 response from a WordPress HTTP response. * - * @since 6.8.0 + * @since 7.0.0 * * @param array $wp_response WordPress HTTP API response array. * @return ResponseInterface PSR-7 response. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php index c9a8f75b9e934..3f6669d84297c 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php @@ -4,17 +4,17 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriFactoryInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Combined PSR-17 factory for creating PSR-7 HTTP message objects. @@ -22,14 +22,14 @@ * Implements all four PSR-17 factory interfaces, delegating to the minimal * WP AI Client PSR-7 implementations. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR17_Factory implements RequestFactoryInterface, ResponseFactoryInterface, StreamFactoryInterface, UriFactoryInterface { /** * Creates a new request. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method The HTTP method associated with the request. * @param UriInterface|string $uri The URI associated with the request. @@ -42,7 +42,7 @@ public function createRequest( string $method, $uri ): RequestInterface { /** * Creates a new response. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $code HTTP status code. Defaults to 200. * @param string $reasonPhrase Reason phrase to associate with status code. @@ -55,7 +55,7 @@ public function createResponse( int $code = 200, string $reasonPhrase = '' ): Re /** * Creates a new stream from a string. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $content String content with which to populate the stream. * @return StreamInterface @@ -67,7 +67,7 @@ public function createStream( string $content = '' ): StreamInterface { /** * Creates a stream from an existing file. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $filename Filename or stream URI to use as basis of stream. * @param string $mode Mode with which to open the underlying filename/stream. @@ -86,7 +86,7 @@ public function createStreamFromFile( string $filename, string $mode = 'r' ): St /** * Creates a new stream from an existing resource. * - * @since 6.8.0 + * @since 7.0.0 * * @param resource $resource PHP resource to use as basis of stream. * @return StreamInterface @@ -104,7 +104,7 @@ public function createStreamFromResource( $resource ): StreamInterface { /** * Creates a new URI. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $uri The URI string. * @return UriInterface diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php index 62ca326f67dba..616a394f397ff 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php @@ -4,12 +4,12 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Minimal PSR-7 HTTP request implementation. @@ -17,14 +17,14 @@ * Immutable value object representing an outgoing HTTP request for the AI Client * HTTP transport layer. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * HTTP method. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $method; @@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Request URI. * - * @since 6.8.0 + * @since 7.0.0 * @var UriInterface */ private $uri; @@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $protocol_version = '1.1'; @@ -50,7 +50,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { * * Each value is an array with 'name' (original case) and 'values' (list of strings). * - * @since 6.8.0 + * @since 7.0.0 * @var array}> */ private $headers = array(); @@ -58,7 +58,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Request body. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamInterface */ private $body; @@ -66,7 +66,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Explicit request target, if set. * - * @since 6.8.0 + * @since 7.0.0 * @var string|null */ private $request_target; @@ -74,7 +74,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method HTTP method. * @param string|UriInterface $uri Request URI. @@ -93,7 +93,7 @@ public function __construct( string $method, $uri ) { /** * Retrieves the HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @return string HTTP protocol version. */ @@ -104,7 +104,7 @@ public function getProtocolVersion(): string { /** * Returns an instance with the specified HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $version HTTP protocol version. * @return static @@ -119,7 +119,7 @@ public function withProtocolVersion( string $version ): self { /** * Retrieves all message header values. * - * @since 6.8.0 + * @since 7.0.0 * * @return string[][] Associative array of headers. */ @@ -136,7 +136,7 @@ public function getHeaders(): array { /** * Checks if a header exists by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return bool @@ -148,7 +148,7 @@ public function hasHeader( string $name ): bool { /** * Retrieves a message header value by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string[] Header values. @@ -166,7 +166,7 @@ public function getHeader( string $name ): array { /** * Retrieves a comma-separated string of the values for a single header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string @@ -178,7 +178,7 @@ public function getHeaderLine( string $name ): string { /** * Returns an instance with the provided value replacing the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @param string|string[] $value Header value(s). @@ -194,7 +194,7 @@ public function withHeader( string $name, $value ): self { /** * Returns an instance with the specified header appended with the given value. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to add. * @param string|string[] $value Header value(s). @@ -223,7 +223,7 @@ public function withAddedHeader( string $name, $value ): self { /** * Returns an instance without the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to remove. * @return static @@ -238,7 +238,7 @@ public function withoutHeader( string $name ): self { /** * Gets the body of the message. * - * @since 6.8.0 + * @since 7.0.0 * * @return StreamInterface */ @@ -249,7 +249,7 @@ public function getBody(): StreamInterface { /** * Returns an instance with the specified message body. * - * @since 6.8.0 + * @since 7.0.0 * * @param StreamInterface $body Body. * @return static @@ -264,7 +264,7 @@ public function withBody( StreamInterface $body ): self { /** * Retrieves the message's request target. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -291,7 +291,7 @@ public function getRequestTarget(): string { /** * Returns an instance with the specific request-target. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $requestTarget Request target. * @return static @@ -306,7 +306,7 @@ public function withRequestTarget( string $requestTarget ): self { /** * Retrieves the HTTP method of the request. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -317,7 +317,7 @@ public function getMethod(): string { /** * Returns an instance with the provided HTTP method. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method Case-sensitive method. * @return static @@ -332,7 +332,7 @@ public function withMethod( string $method ): self { /** * Retrieves the URI instance. * - * @since 6.8.0 + * @since 7.0.0 * * @return UriInterface */ @@ -343,7 +343,7 @@ public function getUri(): UriInterface { /** * Returns an instance with the provided URI. * - * @since 6.8.0 + * @since 7.0.0 * * @param UriInterface $uri New request URI to use. * @param bool $preserveHost Preserve the original state of the Host header. @@ -369,7 +369,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self { /** * Sets a header internally (mutating, for use in constructor and clone methods). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Header name. * @param string|string[] $value Header value(s). diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php index fe84a7dc5dfd1..35c3bba303759 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php @@ -4,11 +4,11 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; /** * Minimal PSR-7 HTTP response implementation. @@ -16,14 +16,14 @@ * Immutable value object representing an incoming HTTP response for the AI Client * HTTP transport layer. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * HTTP status code. * - * @since 6.8.0 + * @since 7.0.0 * @var int */ private $status_code; @@ -31,7 +31,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Reason phrase associated with the status code. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $reason_phrase; @@ -39,7 +39,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $protocol_version = '1.1'; @@ -49,7 +49,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { * * Each value is an array with 'name' (original case) and 'values' (list of strings). * - * @since 6.8.0 + * @since 7.0.0 * @var array}> */ private $headers = array(); @@ -57,7 +57,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Response body. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamInterface */ private $body; @@ -65,7 +65,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $status_code HTTP status code. * @param string $reason_phrase Reason phrase to associate with the status code. @@ -79,7 +79,7 @@ public function __construct( int $status_code = 200, string $reason_phrase = '' /** * Gets the response status code. * - * @since 6.8.0 + * @since 7.0.0 * * @return int Status code. */ @@ -90,7 +90,7 @@ public function getStatusCode(): int { /** * Returns an instance with the specified status code and reason phrase. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $code The 3-digit integer result code to set. * @param string $reasonPhrase The reason phrase to use. @@ -107,7 +107,7 @@ public function withStatus( int $code, string $reasonPhrase = '' ): self { /** * Gets the response reason phrase associated with the status code. * - * @since 6.8.0 + * @since 7.0.0 * * @return string Reason phrase. */ @@ -118,7 +118,7 @@ public function getReasonPhrase(): string { /** * Retrieves the HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @return string HTTP protocol version. */ @@ -129,7 +129,7 @@ public function getProtocolVersion(): string { /** * Returns an instance with the specified HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $version HTTP protocol version. * @return static @@ -144,7 +144,7 @@ public function withProtocolVersion( string $version ): self { /** * Retrieves all message header values. * - * @since 6.8.0 + * @since 7.0.0 * * @return string[][] Associative array of headers. */ @@ -161,7 +161,7 @@ public function getHeaders(): array { /** * Checks if a header exists by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return bool @@ -173,7 +173,7 @@ public function hasHeader( string $name ): bool { /** * Retrieves a message header value by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string[] Header values. @@ -191,7 +191,7 @@ public function getHeader( string $name ): array { /** * Retrieves a comma-separated string of the values for a single header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string @@ -203,7 +203,7 @@ public function getHeaderLine( string $name ): string { /** * Returns an instance with the provided value replacing the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @param string|string[] $value Header value(s). @@ -223,7 +223,7 @@ public function withHeader( string $name, $value ): self { /** * Returns an instance with the specified header appended with the given value. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to add. * @param string|string[] $value Header value(s). @@ -252,7 +252,7 @@ public function withAddedHeader( string $name, $value ): self { /** * Returns an instance without the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to remove. * @return static @@ -267,7 +267,7 @@ public function withoutHeader( string $name ): self { /** * Gets the body of the message. * - * @since 6.8.0 + * @since 7.0.0 * * @return StreamInterface */ @@ -278,7 +278,7 @@ public function getBody(): StreamInterface { /** * Returns an instance with the specified message body. * - * @since 6.8.0 + * @since 7.0.0 * * @param StreamInterface $body Body. * @return static diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php index 273b04a8fb669..5ba6395e45754 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php @@ -4,10 +4,10 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; /** * Minimal string-backed PSR-7 stream implementation. @@ -15,14 +15,14 @@ * Provides the StreamInterface methods needed by the AI Client HTTP transport * layer without requiring PHP stream resources. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * The string content of the stream. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $content; @@ -30,7 +30,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * Current read/write offset position. * - * @since 6.8.0 + * @since 7.0.0 * @var int */ private $offset = 0; @@ -38,7 +38,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $content Initial content for the stream. */ @@ -49,7 +49,7 @@ public function __construct( string $content = '' ) { /** * Reads all data from the stream into a string. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -60,7 +60,7 @@ public function __toString(): string { /** * Closes the stream. No-op for string-backed streams. * - * @since 6.8.0 + * @since 7.0.0 */ public function close(): void { // No-op. @@ -69,7 +69,7 @@ public function close(): void { /** * Separates any underlying resources from the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return resource|null Always null for string-backed streams. */ @@ -80,7 +80,7 @@ public function detach() { /** * Gets the size of the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return int|null The size in bytes. */ @@ -91,7 +91,7 @@ public function getSize(): ?int { /** * Returns the current position of the read/write pointer. * - * @since 6.8.0 + * @since 7.0.0 * * @return int Position of the pointer. */ @@ -102,7 +102,7 @@ public function tell(): int { /** * Returns true if the stream is at the end. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool */ @@ -113,7 +113,7 @@ public function eof(): bool { /** * Returns whether the stream is seekable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -124,7 +124,7 @@ public function isSeekable(): bool { /** * Seeks to a position in the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $offset Stream offset. * @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END. @@ -152,7 +152,7 @@ public function seek( int $offset, int $whence = SEEK_SET ): void { /** * Seeks to the beginning of the stream. * - * @since 6.8.0 + * @since 7.0.0 */ public function rewind(): void { $this->offset = 0; @@ -161,7 +161,7 @@ public function rewind(): void { /** * Returns whether the stream is writable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -172,7 +172,7 @@ public function isWritable(): bool { /** * Writes data to the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $string The string to write. * @return int Number of bytes written. @@ -188,7 +188,7 @@ public function write( string $string ): int { /** * Returns whether the stream is readable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -199,7 +199,7 @@ public function isReadable(): bool { /** * Reads data from the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $length Number of bytes to read. * @return string Data read from the stream. @@ -214,7 +214,7 @@ public function read( int $length ): string { /** * Returns the remaining contents of the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -228,7 +228,7 @@ public function getContents(): string { /** * Gets stream metadata. * - * @since 6.8.0 + * @since 7.0.0 * * @param string|null $key Specific metadata to retrieve. * @return array|mixed|null Returns null for specific keys, empty array otherwise. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php index 8ea0cf4546b7a..58dfb364d469b 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php @@ -4,24 +4,24 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Minimal PSR-7 URI implementation. * * Wraps PHP's parse_url() components into an immutable UriInterface value object. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * Standard ports for HTTP and HTTPS. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static $default_ports = array( @@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI scheme (e.g. "http", "https"). * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $scheme = ''; @@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI user info (e.g. "user:password"). * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $user_info = ''; @@ -48,7 +48,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI host. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $host = ''; @@ -56,7 +56,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI port. * - * @since 6.8.0 + * @since 7.0.0 * @var int|null */ private $port; @@ -64,7 +64,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI path. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $path = ''; @@ -72,7 +72,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI query string. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $query = ''; @@ -80,7 +80,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI fragment. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $fragment = ''; @@ -88,7 +88,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $uri URI string to parse. */ @@ -118,7 +118,7 @@ public function __construct( string $uri = '' ) { /** * Retrieves the scheme component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI scheme. */ @@ -129,7 +129,7 @@ public function getScheme(): string { /** * Retrieves the authority component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI authority, in "[user-info@]host[:port]" format. */ @@ -154,7 +154,7 @@ public function getAuthority(): string { /** * Retrieves the user information component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI user information. */ @@ -165,7 +165,7 @@ public function getUserInfo(): string { /** * Retrieves the host component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI host. */ @@ -176,7 +176,7 @@ public function getHost(): string { /** * Retrieves the port component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return int|null The URI port, or null if standard or not set. */ @@ -191,7 +191,7 @@ public function getPort(): ?int { /** * Retrieves the path component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI path. */ @@ -202,7 +202,7 @@ public function getPath(): string { /** * Retrieves the query string of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI query string. */ @@ -213,7 +213,7 @@ public function getQuery(): string { /** * Retrieves the fragment component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI fragment. */ @@ -224,7 +224,7 @@ public function getFragment(): string { /** * Returns an instance with the specified scheme. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $scheme The scheme to use with the new instance. * @return static A new instance with the specified scheme. @@ -239,7 +239,7 @@ public function withScheme( string $scheme ): UriInterface { /** * Returns an instance with the specified user information. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $user The user name to use for authority. * @param string|null $password The password associated with $user. @@ -259,7 +259,7 @@ public function withUserInfo( string $user, ?string $password = null ): UriInter /** * Returns an instance with the specified host. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $host The hostname to use with the new instance. * @return static A new instance with the specified host. @@ -274,7 +274,7 @@ public function withHost( string $host ): UriInterface { /** * Returns an instance with the specified port. * - * @since 6.8.0 + * @since 7.0.0 * * @param int|null $port The port to use with the new instance. * @return static A new instance with the specified port. @@ -289,7 +289,7 @@ public function withPort( ?int $port ): UriInterface { /** * Returns an instance with the specified path. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $path The path to use with the new instance. * @return static A new instance with the specified path. @@ -304,7 +304,7 @@ public function withPath( string $path ): UriInterface { /** * Returns an instance with the specified query string. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $query The query string to use with the new instance. * @return static A new instance with the specified query string. @@ -319,7 +319,7 @@ public function withQuery( string $query ): UriInterface { /** * Returns an instance with the specified URI fragment. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $fragment The fragment to use with the new instance. * @return static A new instance with the specified fragment. @@ -334,7 +334,7 @@ public function withFragment( string $fragment ): UriInterface { /** * Returns the string representation as a URI reference. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -374,7 +374,7 @@ public function __toString(): string { /** * Checks whether the current port is the standard port for the scheme. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool True if port is the standard port for the current scheme. */ diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index 999adeb6e9f90..2a5c2ef53b911 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClient\Builders\PromptBuilder; @@ -38,7 +38,7 @@ * calls will be no-ops that just return the same error state instance. Only * when a generating method is called, the WP_Error will be returned. * - * @since 6.8.0 + * @since 7.0.0 * * @method self with_text(string $text) Adds text to the current message. * @method self with_file($file, ?string $mimeType = null) Adds a file to the current message. @@ -94,7 +94,7 @@ class WP_AI_Client_Prompt_Builder { /** * Wrapped prompt builder instance from the PHP AI Client SDK. * - * @since 6.8.0 + * @since 7.0.0 * @var PromptBuilder */ private PromptBuilder $builder; @@ -102,7 +102,7 @@ class WP_AI_Client_Prompt_Builder { /** * WordPress error instance, if any error occurred during method calls. * - * @since 6.8.0 + * @since 7.0.0 * @var WP_Error|null */ private ?WP_Error $error = null; @@ -112,7 +112,7 @@ class WP_AI_Client_Prompt_Builder { * * Structured as a map for faster lookups. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static array $generating_methods = array( @@ -136,7 +136,7 @@ class WP_AI_Client_Prompt_Builder { * * Structured as a map for faster lookups. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static array $support_check_methods = array( @@ -153,7 +153,7 @@ class WP_AI_Client_Prompt_Builder { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param mixed $prompt Optional initial prompt content. @@ -164,7 +164,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) { /** * Filters the default request timeout in seconds for AI Client HTTP requests. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $default_timeout The default timeout in seconds. */ @@ -185,7 +185,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) { * Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix * naming convention and passes them to the underlying prompt builder. * - * @since 6.8.0 + * @since 7.0.0 * * @param WP_Ability|string ...$abilities The abilities to register, either as WP_Ability objects or ability name strings. * @return self The current instance for method chaining. @@ -226,7 +226,7 @@ public function using_abilities( ...$abilities ): self { * any exceptions thrown, stores them, and returns a WP_Error when a terminate method * is called. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name in snake_case. * @param array $arguments The method arguments. @@ -252,7 +252,7 @@ public function __call( string $name, array $arguments ) { /** * Filters whether to prevent the prompt from being executed. * - * @since 6.8.0 + * @since 7.0.0 * * @param bool $prevent Whether to prevent the prompt. Default false. * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). @@ -310,7 +310,7 @@ public function __call( string $name, array $arguments ) { /** * Checks if a method name is a support check method (is_supported*). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name. * @return bool True if the method is a support check method, false otherwise. @@ -322,7 +322,7 @@ private static function is_support_check_method( string $name ): bool { /** * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name. * @return bool True if the method is a generating method, false otherwise. @@ -334,7 +334,7 @@ private static function is_generating_method( string $name ): bool { /** * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name in snake_case. * @return callable The callable for the specified method. @@ -360,7 +360,7 @@ protected function get_builder_callable( string $name ): callable { /** * Converts snake_case to camelCase. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $snake_case The snake_case string. * @return string The camelCase string. diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php index 7cd81ed038277..b4305ff4c7ed8 100644 --- a/src/wp-includes/php-ai-client/autoload.php +++ b/src/wp-includes/php-ai-client/autoload.php @@ -16,21 +16,13 @@ spl_autoload_register( static function ( $class_name ) { // Namespace prefix for the AI client. - $client_prefix = 'WordPress\\AiClient\\'; + $client_prefix = 'WordPress\\AiClient\\'; $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) - // Namespace prefix for scoped dependencies. + // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.). $scoped_prefix = 'WordPress\\AiClientDependencies\\'; $scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' ) - // PSR interface namespaces (not scoped, kept global). - $psr_prefixes = array( - 'Psr\\Http\\Client\\' => 16, - 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 20, - 'Psr\\SimpleCache\\' => 16, - ); - $base_dir = __DIR__; // 1. WordPress\AiClient\* → src/ @@ -52,17 +44,5 @@ static function ( $class_name ) { } return; } - - // 3. Psr\* interfaces → third-party/Psr/... - foreach ( $psr_prefixes as $prefix => $prefix_len ) { - if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { - $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. - $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; - if ( file_exists( $file ) ) { - require $file; - } - return; - } - } } ); diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index fb8e1ced1f4d2..f851cfe82d5dc 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -3,8 +3,8 @@ declare (strict_types=1); namespace WordPress\AiClient; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\SimpleCache\CacheInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php index d135df56c97fe..6821b99280bd3 100644 --- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -3,7 +3,7 @@ declare (strict_types=1); namespace WordPress\AiClient\Builders; -use Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php index dddfb952a2449..b6a088725f3d5 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php @@ -3,8 +3,8 @@ declare (strict_types=1); namespace WordPress\AiClient\Providers\Http\Contracts; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; /** * Interface for HTTP clients that support per-request transport options. diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php index 211daf5ec7acd..8d62f01746632 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\DTO; use JsonException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php index 8b4977eb14738..1b26ac2c60f0b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php @@ -3,7 +3,7 @@ declare (strict_types=1); namespace WordPress\AiClient\Providers\Http\Exception; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Request; /** diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php index 0dc8e56c82a18..dd6cc3e9e4c4b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php @@ -5,11 +5,11 @@ use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery; use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; @@ -75,9 +75,9 @@ public function send(Request $request, ?RequestOptions $options = null): Respons } else { $psr7Response = $this->client->sendRequest($psr7Request); } - } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { + } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); - } catch (\Psr\Http\Client\ClientExceptionInterface $e) { + } catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) { // Handle other PSR-18 client exceptions that are not network-related throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e); } diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php index f84213a167212..62193c03c9abc 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client; -use Psr\Http\Client\ClientExceptionInterface as PsrClientException; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface as PsrClientException; /** * Every HTTP Client related Exception must implement this interface. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php index 6e05303eaafc7..fabf0d0486a99 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; /** * Thrown when a response was received but the request itself failed. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php index ece5bdf587362..73bee0c013eea 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Thrown when the request cannot be completed because of network issues. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php index fe337b0a34675..dc0c0d60666d8 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; trait RequestAwareTrait { /** diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php index ec080724b889b..036e6182590ec 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Client\RequestExceptionInterface as PsrRequestException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\RequestExceptionInterface as PsrRequestException; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Exception for when a request failed, providing access to the failed request. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php index 4b45bdf90f554..2d7399c385b7e 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php @@ -3,7 +3,7 @@ namespace WordPress\AiClientDependencies\Http\Client; use WordPress\AiClientDependencies\Http\Promise\Promise; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Sends a PSR-7 Request in an asynchronous way by returning a Promise. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php index 244b9ddb7dbc6..5ea57d8c7a735 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * {@inheritdoc} * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php index 52a278e32c7f5..be344a4834401 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php @@ -4,7 +4,7 @@ use WordPress\AiClientDependencies\Http\Client\Exception; use WordPress\AiClientDependencies\Http\Promise\Promise; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; final class HttpFulfilledPromise implements Promise { /** diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php index ed28ffc0b06a4..389eede1b5027 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php @@ -1,24 +1,24 @@ 'symfony/framework-bundle', 'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7', 'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6', 'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5', 'php-http/cakephp-adapter' => 'cakephp/cakephp', 'php-http/react-adapter' => 'react/event-loop', 'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1', 'php-http/artax-adapter' => 'amphp/artax:^3', 'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1', 'http-interop/http-factory-slim' => 'slim/slim:^3']; - private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['Psr\Http\Message\RequestFactoryInterface', 'Psr\Http\Message\ResponseFactoryInterface', 'Psr\Http\Message\ServerRequestFactoryInterface', 'Psr\Http\Message\StreamFactoryInterface', 'Psr\Http\Message\UploadedFileFactoryInterface', 'Psr\Http\Message\UriFactoryInterface']]; + private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface']]; public static function getSubscribedEvents(): array { return [ScriptEvents::PRE_AUTOLOAD_DUMP => 'preAutoloadDump', ScriptEvents::POST_UPDATE_CMD => 'postUpdate']; diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php index 561f76b0914b8..2f8880a7111df 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php @@ -2,18 +2,18 @@ namespace WordPress\AiClientDependencies\Http\Discovery; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UploadedFileInterface; -use Psr\Http\Message\UriFactoryInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * A generic PSR-17 implementation. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php index d9e5f9cd42f27..5e22ab1dd03c0 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php @@ -4,12 +4,12 @@ use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException; use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * Finds PSR-17 factories. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php index 83ed4ce970631..55de2592340f3 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php @@ -2,15 +2,15 @@ namespace WordPress\AiClientDependencies\Http\Discovery; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * A generic PSR-18 and PSR-17 implementation. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php index 9093e74df078b..ceca0e4a515b5 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php @@ -4,7 +4,7 @@ use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException; use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * Finds a PSR-18 HTTP Client. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php index 02b3fdbf8a5b8..e9c65c8220e93 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php @@ -33,8 +33,8 @@ use WordPress\AiClientDependencies\Http\Message\UriFactory\SlimUriFactory; use WordPress\AiClientDependencies\Laminas\Diactoros\Request as DiactorosRequest; use WordPress\AiClientDependencies\Nyholm\Psr7\Factory\HttplugFactory as NyholmHttplugFactory; -use Psr\Http\Client\ClientInterface as Psr18Client; -use Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface as Psr18Client; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory; use WordPress\AiClientDependencies\Slim\Http\Request as SlimRequest; use WordPress\AiClientDependencies\Symfony\Component\HttpClient\HttplugClient as SymfonyHttplug; use WordPress\AiClientDependencies\Symfony\Component\HttpClient\Psr18Client as SymfonyPsr18; diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php index 3e5227f6d56ce..7a310542c13c4 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php @@ -2,12 +2,12 @@ namespace WordPress\AiClientDependencies\Http\Discovery\Strategy; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * @internal * diff --git a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php index d522445fce250..4b85d3d500600 100644 --- a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php +++ b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php @@ -1,7 +1,7 @@ "$TARGET_DIR/autoload.php" << 'AUTOLOAD_PHP' * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ // Load polyfills (each function is guarded by function_exists). @@ -219,21 +219,13 @@ require_once __DIR__ . '/src/polyfills.php'; spl_autoload_register( static function ( $class_name ) { // Namespace prefix for the AI client. - $client_prefix = 'WordPress\\AiClient\\'; - $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' ) + $client_prefix = 'WordPress\\AiClient\\'; + $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) - // Namespace prefix for scoped dependencies. + // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.). $scoped_prefix = 'WordPress\\AiClientDependencies\\'; $scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' ) - // PSR interface namespaces (not scoped, kept global). - $psr_prefixes = array( - 'Psr\\Http\\Client\\' => 16, - 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 21, - 'Psr\\SimpleCache\\' => 16, - ); - $base_dir = __DIR__; // 1. WordPress\AiClient\* → src/ @@ -255,18 +247,6 @@ spl_autoload_register( } return; } - - // 3. Psr\* interfaces → third-party/Psr/... - foreach ( $psr_prefixes as $prefix => $prefix_len ) { - if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { - $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. - $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; - if ( file_exists( $file ) ) { - require $file; - } - return; - } - } } ); AUTOLOAD_PHP @@ -316,10 +296,14 @@ if [ -d "$TARGET_DIR/third-party/Http" ]; then fi fi -# Check that Psr interfaces are NOT scoped. +# Check that Psr interfaces are scoped. if [ -d "$TARGET_DIR/third-party/Psr" ]; then - UNSCOPED_PSR=$(grep -rL "namespace WordPress\\\\AiClientDependencies" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ') - echo " Found $UNSCOPED_PSR unscoped Psr\\* files." + SCOPED_PSR=$(grep -rl "namespace WordPress\\\\AiClientDependencies\\\\Psr" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ') + if [ "$SCOPED_PSR" -eq 0 ]; then + echo "Warning: No scoped Psr\\* namespaces found in third-party/Psr/." + else + echo " Found $SCOPED_PSR scoped Psr\\* files." + fi fi if [ "$ERRORS" -gt 0 ]; then diff --git a/tools/php-ai-client/scoper.inc.php b/tools/php-ai-client/scoper.inc.php index cbe0428a9909b..f08a4ebaf4f41 100644 --- a/tools/php-ai-client/scoper.inc.php +++ b/tools/php-ai-client/scoper.inc.php @@ -2,11 +2,8 @@ /** * PHP-Scoper configuration for bundling php-ai-client dependencies. * - * Scopes Http\* namespaces (php-http packages) to WordPress\AiClientDependencies\Http\* - * to avoid conflicts with plugin-bundled versions. - * - * PSR interfaces (Psr\*) are excluded from scoping so that external HTTP - * implementations (Guzzle, Nyholm, etc.) remain type-compatible. + * Scopes all third-party namespaces (Http\*, Psr\*, etc.) to + * WordPress\AiClientDependencies\* to avoid conflicts with plugin-bundled versions. * * @package WordPress */ @@ -22,7 +19,7 @@ ->files() ->ignoreVCS( true ) ->notName( '/LICENSE|.*\\.md|.*\\.dist|Makefile/' ) - ->exclude( array( 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) ) + ->exclude( array( 'composer', 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) ) ->in( 'vendor' ), // Include the AI client source files so `use` statements referencing @@ -38,15 +35,6 @@ 'exclude-namespaces' => array( // The AI client's own namespace must not be scoped. 'WordPress\\AiClient', - - // PSR interfaces stay global for type compatibility with external implementations. - 'Psr\\Http\\Client', - 'Psr\\Http\\Message', - 'Psr\\EventDispatcher', - 'Psr\\SimpleCache', - - // Composer's own namespace. - 'Composer', ), 'exclude-files' => array(), From 169ddffbb2ee9b3bc55d55962d43a9665f706c5c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 17:56:08 -0700 Subject: [PATCH 7/8] feat: adds wp_ai_client_prompt function --- src/wp-includes/ai-client.php | 22 +++++ src/wp-settings.php | 1 + .../tests/ai-client/wpAiClientPrompt.php | 92 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/wp-includes/ai-client.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPrompt.php diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php new file mode 100644 index 0000000000000..1ceccbbb35d77 --- /dev/null +++ b/src/wp-includes/ai-client.php @@ -0,0 +1,22 @@ +assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $builder ); + } + + /** + * Test that wp_ai_client_prompt() wraps a PromptBuilder internally. + * + * @ticket TBD + */ + public function test_wraps_sdk_prompt_builder() { + $builder = wp_ai_client_prompt(); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $property = $reflection->getProperty( 'builder' ); + $property->setAccessible( true ); + + $this->assertInstanceOf( PromptBuilder::class, $property->getValue( $builder ) ); + } + + /** + * Test that wp_ai_client_prompt() passes prompt content to the builder. + * + * @ticket TBD + */ + public function test_passes_prompt_content() { + $builder = wp_ai_client_prompt( 'Hello, AI!' ); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped = $builder_property->getValue( $builder ); + + $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) ); + $messages_property = $wrapped_reflection->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + $messages = $messages_property->getValue( $wrapped ); + + $this->assertNotEmpty( $messages, 'Prompt content should produce at least one message.' ); + } + + /** + * Test that wp_ai_client_prompt() without arguments creates builder with no messages. + * + * @ticket TBD + */ + public function test_no_prompt_creates_empty_builder() { + $builder = wp_ai_client_prompt(); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped = $builder_property->getValue( $builder ); + + $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) ); + $messages_property = $wrapped_reflection->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + $messages = $messages_property->getValue( $wrapped ); + + $this->assertEmpty( $messages, 'No prompt content should produce no messages.' ); + } + + /** + * Test that successive calls return independent builder instances. + * + * @ticket TBD + */ + public function test_returns_independent_instances() { + $builder1 = wp_ai_client_prompt( 'First' ); + $builder2 = wp_ai_client_prompt( 'Second' ); + + $this->assertNotSame( $builder1, $builder2 ); + } +} From 7e30d9761eac80b4fa5a22c23ee0762d2f2adf27 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 18:01:15 -0700 Subject: [PATCH 8/8] refactor: corrects formatting issues --- .../class-wp-ai-client-psr7-request.php | 2 +- .../class-wp-ai-client-psr7-response.php | 6 +++--- .../class-wp-ai-client-prompt-builder.php | 14 +++++++------- .../tests/ai-client/wpAiClientEventDispatcher.php | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php index 616a394f397ff..1afc8ba87e974 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php @@ -375,7 +375,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self { * @param string|string[] $value Header value(s). */ private function set_header_internal( string $name, $value ): void { - $normalized = strtolower( $name ); + $normalized = strtolower( $name ); $this->headers[ $normalized ] = array( 'name' => $name, 'values' => is_array( $value ) ? $value : array( $value ), diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php index 35c3bba303759..eb84d2edd73ba 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php @@ -210,9 +210,9 @@ public function getHeaderLine( string $name ): string { * @return static */ public function withHeader( string $name, $value ): self { - $new = clone $this; - $normalized = strtolower( $name ); - $new->headers[ $normalized ] = array( + $new = clone $this; + $normalized = strtolower( $name ); + $new->headers[ $normalized ] = array( 'name' => $name, 'values' => is_array( $value ) ? $value : array( $value ), ); diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index 2a5c2ef53b911..e4a7c656ffdda 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -140,14 +140,14 @@ class WP_AI_Client_Prompt_Builder { * @var array */ private static array $support_check_methods = array( - 'is_supported' => true, - 'is_supported_for_text_generation' => true, - 'is_supported_for_image_generation' => true, + 'is_supported' => true, + 'is_supported_for_text_generation' => true, + 'is_supported_for_image_generation' => true, 'is_supported_for_text_to_speech_conversion' => true, - 'is_supported_for_video_generation' => true, - 'is_supported_for_speech_generation' => true, - 'is_supported_for_music_generation' => true, - 'is_supported_for_embedding_generation' => true, + 'is_supported_for_video_generation' => true, + 'is_supported_for_speech_generation' => true, + 'is_supported_for_music_generation' => true, + 'is_supported_for_embedding_generation' => true, ); /** diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php index 6e7c7aac40953..3cd621f09bf2c 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php @@ -19,7 +19,7 @@ public function test_dispatch_fires_action_hook() { $dispatcher = new WP_AI_Client_Event_Dispatcher(); $event = new WP_AI_Client_Mock_Event(); - $hook_fired = false; + $hook_fired = false; $fired_event = null; add_action( @@ -43,8 +43,8 @@ function ( $e ) use ( &$hook_fired, &$fired_event ) { * @ticket TBD */ public function test_dispatch_returns_event_without_listeners() { - $dispatcher = new WP_AI_Client_Event_Dispatcher(); - $event = new stdClass(); + $dispatcher = new WP_AI_Client_Event_Dispatcher(); + $event = new stdClass(); $event->test_value = 'original'; $result = $dispatcher->dispatch( $event );