From 1757ace16e55f6c54eaf79437f13ebb26b94d5f0 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 5 Feb 2026 23:03:33 +0000 Subject: [PATCH 001/147] Docs: Document the globals in `wp_print_plugin_file_tree()`. Follow-up to [41851]. Props noruzzaman, huzaifaalmesbah, shailu25, sabernhardt. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61591 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/misc.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index 6c00a0ffb1951..d5a861638a2e7 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -492,6 +492,9 @@ function wp_make_plugin_file_tree( $plugin_editable_files ) { * @since 4.9.0 * @access private * + * @global string $file Path to the file being edited. + * @global string $plugin Path to the plugin file relative to the plugins directory. + * * @param array|string $tree List of file/folder paths, or filename. * @param string $label Name of file or folder to print. * @param int $level The aria-level for the current iteration. From e5606f2e2930f97757b7462aaf68432be09ca626 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 6 Feb 2026 09:23:10 +0000 Subject: [PATCH 002/147] Docs: Add missing `@global` descriptions in `wp-admin/includes/schema.php`. Follow-up to [32643], [45734]. Props noruzzaman, huzaifaalmesbah, shailu25, sabernhardt. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61592 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/schema.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 9ef4078f89054..7c762991b03bf 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -12,8 +12,8 @@ * Declare these as global in case schema.php is included from a function. * * @global wpdb $wpdb WordPress database abstraction object. - * @global array $wp_queries - * @global string $charset_collate + * @global array $wp_queries Global database queries array. + * @global string $charset_collate Database charset and collation. */ global $wpdb, $wp_queries, $charset_collate; From 8451b2937f4e56f88c4f78dc730fdfff0756da6e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Feb 2026 02:27:18 +0000 Subject: [PATCH 003/147] General: Add AI Guidelines to the pull request template. Developed in https://github.com/WordPress/wordpress-develop/pull/10850 Props westonruter, justlevine, mukesh27, jeffpaul. See #64587. git-svn-id: https://develop.svn.wordpress.org/trunk@61593 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/pull_request_template.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b53da615a7aae..652c7439af8d2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,11 +12,18 @@ If this is your first time contributing, you may also find reviewing these guide - Inline Documentation Standards: https://make.wordpress.org/core/handbook/best-practices/inline-documentation-standards/ - Browser Support Policies: https://make.wordpress.org/core/handbook/best-practices/browser-support/ - Proper spelling and grammar related best practices: https://make.wordpress.org/core/handbook/best-practices/spelling/ +- ✨ If you are using AI tools, you must adhere to the AI Guidelines: https://make.wordpress.org/ai/handbook/ai-guidelines/ --> Trac ticket: +## Use of AI Tools + + + --- **This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See [GitHub Pull Requests for Code Review](https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/) in the Core Handbook for more details.** From 45a4e0f619e9bde56fe6f3fc338e8a4cb659c592 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Feb 2026 06:05:09 +0000 Subject: [PATCH 004/147] Docs: Add descriptions and improved typing to `@return` tags in various admin and includes files. Developed in https://github.com/WordPress/wordpress-develop/pull/10863 Props huzaifaalmesbah, westonruter, noruzzaman, mukesh27. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61594 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-debug-data.php | 28 +++++++++---------- src/wp-admin/includes/class-wp-list-table.php | 18 ++++++------ .../includes/class-wp-media-list-table.php | 14 +++++----- src/wp-admin/includes/class-wp-screen.php | 2 +- .../class-wp-theme-install-list-table.php | 4 +-- src/wp-admin/includes/media.php | 22 ++++++++------- src/wp-admin/includes/misc.php | 6 ++-- src/wp-admin/includes/update.php | 22 +++++++-------- src/wp-includes/class-wp-http-ixr-client.php | 2 +- src/wp-includes/vars.php | 2 +- 10 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php index 9f914ab882ae8..aa0f3eb10ce45 100644 --- a/src/wp-admin/includes/class-wp-debug-data.php +++ b/src/wp-admin/includes/class-wp-debug-data.php @@ -142,7 +142,7 @@ static function ( $section ) { * * @since 6.7.0 * - * @return array + * @return array The debug data for the Info screen. */ private static function get_wp_core(): array { // Save few function calls. @@ -305,7 +305,7 @@ private static function get_wp_core(): array { * * @since 6.7.0 * - * @return array + * @return array The drop-ins debug data. */ private static function get_wp_dropins(): array { // Get a list of all drop-in replacements. @@ -340,7 +340,7 @@ private static function get_wp_dropins(): array { * * @since 6.7.0 * - * @return array + * @return array The server-related debug data. */ private static function get_wp_server(): array { // Populate the server debug fields. @@ -561,7 +561,7 @@ private static function get_wp_server(): array { * @since 6.7.0 * * @throws ImagickException - * @return array + * @return array The media handling debug data. */ private static function get_wp_media(): array { // Spare few function calls. @@ -773,7 +773,7 @@ private static function get_wp_media(): array { * * @since 6.7.0 * - * @return array + * @return array The must-use plugins debug data. */ private static function get_wp_mu_plugins(): array { // List must use plugins if there are any. @@ -904,7 +904,7 @@ private static function get_wp_paths_sizes(): ?array { * * @since 6.7.0 * - * @return array + * @return array The active plugins debug data. */ private static function get_wp_plugins_active(): array { return array( @@ -919,7 +919,7 @@ private static function get_wp_plugins_active(): array { * * @since 6.7.0 * - * @return array + * @return array The inactive plugins debug data. */ private static function get_wp_plugins_inactive(): array { return array( @@ -934,7 +934,7 @@ private static function get_wp_plugins_inactive(): array { * * @since 6.7.0 * - * @return array + * @return array>> The raw plugin debug data for active and inactive plugins. */ private static function get_wp_plugins_raw_data(): array { // List all available plugins. @@ -1057,7 +1057,7 @@ private static function get_wp_plugins_raw_data(): array { * * @global array $_wp_theme_features * - * @return array + * @return array The active theme debug data. */ private static function get_wp_active_theme(): array { global $_wp_theme_features; @@ -1201,7 +1201,7 @@ private static function get_wp_active_theme(): array { * * @since 6.7.0 * - * @return array + * @return array The parent theme debug data. */ private static function get_wp_parent_theme(): array { $theme_updates = get_theme_updates(); @@ -1313,7 +1313,7 @@ private static function get_wp_parent_theme(): array { * * @since 6.7.0 * - * @return array + * @return array The inactive themes debug data. */ private static function get_wp_themes_inactive(): array { $active_theme = wp_get_theme(); @@ -1444,7 +1444,7 @@ private static function get_wp_themes_inactive(): array { * * @since 6.7.0 * - * @return array + * @return array The WordPress constants debug data. */ private static function get_wp_constants(): array { // Check if WP_DEBUG_LOG is set. @@ -1613,7 +1613,7 @@ private static function get_wp_constants(): array { * * @global wpdb $wpdb WordPress database abstraction object. * - * @return array + * @return array The database debug data. */ private static function get_wp_database(): array { global $wpdb; @@ -1695,7 +1695,7 @@ private static function get_wp_database(): array { * * @since 6.7.0 * - * @return array + * @return array The debug data and other information for the Info screen. */ private static function get_wp_filesystem(): array { $upload_dir = wp_upload_dir(); diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index a67921824397a..e31e89a36fb5a 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -352,7 +352,7 @@ public function get_pagination_arg( $key ) { * * @since 3.1.0 * - * @return bool + * @return bool Whether the table has items to display. */ public function has_items() { return ! empty( $this->items ); @@ -490,7 +490,7 @@ protected function get_views_links( $link_data = array() ) { * * @since 3.1.0 * - * @return array + * @return array An associative array of views. */ protected function get_views() { return array(); @@ -554,7 +554,7 @@ public function views() { * @since 3.1.0 * @since 5.6.0 A bulk action can now contain an array of options in order to create an optgroup. * - * @return array + * @return array> An associative array of bulk actions. */ protected function get_bulk_actions() { return array(); @@ -954,7 +954,7 @@ protected function comments_bubble( $post_id, $pending_comments ) { * * @since 3.1.0 * - * @return int + * @return int Current page number. */ public function get_pagenum() { $pagenum = isset( $_REQUEST['paged'] ) ? absint( $_REQUEST['paged'] ) : 0; @@ -973,7 +973,7 @@ public function get_pagenum() { * * @param string $option User option name. * @param int $default_value Optional. The number of items to display. Default 20. - * @return int + * @return int Number of items to display per page. */ protected function get_items_per_page( $option, $default_value = 20 ) { $per_page = (int) get_user_option( $option ); @@ -1179,7 +1179,7 @@ protected function pagination( $which ) { * @since 3.1.0 * @abstract * - * @return array + * @return array An associative array of columns. */ public function get_columns() { die( 'function WP_List_Table::get_columns() must be overridden in a subclass.' ); @@ -1202,7 +1202,7 @@ public function get_columns() { * @since 3.1.0 * @since 6.3.0 Added 'abbr', 'orderby-text' and 'initially-sorted-column-order'. * - * @return array + * @return array|string> An associative array of sortable columns. */ protected function get_sortable_columns() { return array(); @@ -1293,7 +1293,7 @@ protected function get_primary_column_name() { * * @since 3.1.0 * - * @return array + * @return array Column information. */ protected function get_column_info() { // $_column_headers is already set / cached. @@ -1376,7 +1376,7 @@ protected function get_column_info() { * * @since 3.1.0 * - * @return int + * @return int The number of visible columns. */ public function get_column_count() { list ( $columns, $hidden ) = $this->get_column_info(); diff --git a/src/wp-admin/includes/class-wp-media-list-table.php b/src/wp-admin/includes/class-wp-media-list-table.php index 2604c394be2f0..5e26ebb9e118b 100644 --- a/src/wp-admin/includes/class-wp-media-list-table.php +++ b/src/wp-admin/includes/class-wp-media-list-table.php @@ -53,7 +53,7 @@ public function __construct( $args = array() ) { } /** - * @return bool + * @return bool Whether the user can upload files. */ public function ajax_user_can() { return current_user_can( 'upload_files' ); @@ -119,7 +119,7 @@ public function prepare_items() { /** * @global array $post_mime_types * @global array $avail_post_mime_types - * @return array + * @return array An array of links for the available views. */ protected function get_views() { global $post_mime_types, $avail_post_mime_types; @@ -174,7 +174,7 @@ protected function get_views() { } /** - * @return array + * @return array An associative array of bulk actions. */ protected function get_bulk_actions() { $actions = array(); @@ -227,7 +227,7 @@ protected function extra_tablenav( $which ) { } /** - * @return string + * @return string|false The current action. */ public function current_action() { if ( isset( $_REQUEST['found_post_id'] ) && isset( $_REQUEST['media'] ) ) { @@ -246,7 +246,7 @@ public function current_action() { } /** - * @return bool + * @return bool Whether the list table has items to display. */ public function has_items() { return have_posts(); @@ -393,7 +393,7 @@ public function get_columns() { } /** - * @return array + * @return array> An array of sortable columns. */ protected function get_sortable_columns() { return array( @@ -764,7 +764,7 @@ protected function get_default_primary_column_name() { /** * @param WP_Post $post * @param string $att_title - * @return array + * @return array An array of row actions. */ private function _get_row_actions( $post, $att_title ) { $actions = array(); diff --git a/src/wp-admin/includes/class-wp-screen.php b/src/wp-admin/includes/class-wp-screen.php index 6bb8681cc612d..838f0795cd5d3 100644 --- a/src/wp-admin/includes/class-wp-screen.php +++ b/src/wp-admin/includes/class-wp-screen.php @@ -988,7 +988,7 @@ public function render_screen_meta() { * * @global array $wp_meta_boxes Global meta box state. * - * @return bool + * @return bool Whether to show the Screen Options tab for the current screen. */ public function show_screen_options() { global $wp_meta_boxes; diff --git a/src/wp-admin/includes/class-wp-theme-install-list-table.php b/src/wp-admin/includes/class-wp-theme-install-list-table.php index 7e00005ba4372..696b088d1d716 100644 --- a/src/wp-admin/includes/class-wp-theme-install-list-table.php +++ b/src/wp-admin/includes/class-wp-theme-install-list-table.php @@ -19,7 +19,7 @@ class WP_Theme_Install_List_Table extends WP_Themes_List_Table { public $features = array(); /** - * @return bool + * @return bool Whether the user can install themes. */ public function ajax_user_can() { return current_user_can( 'install_themes' ); @@ -179,7 +179,7 @@ public function no_items() { /** * @global array $tabs * @global string $tab - * @return array + * @return array An array of links for the available views. */ protected function get_views() { global $tabs, $tab; diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php index 6a6d258ae6e7d..7cbde12d787f1 100644 --- a/src/wp-admin/includes/media.php +++ b/src/wp-admin/includes/media.php @@ -868,7 +868,7 @@ function media_upload_form_handler() { * * @since 2.5.0 * - * @return null|string + * @return null|string The form handler result or null. */ function wp_media_upload_handler() { $errors = array(); @@ -1096,7 +1096,7 @@ function media_sideload_image( $file, $post_id = 0, $desc = null, $return_type = * * @since 2.5.0 * - * @return string|null + * @return string|null The form handler result or null. */ function media_upload_gallery() { $errors = array(); @@ -1122,7 +1122,7 @@ function media_upload_gallery() { * * @since 2.5.0 * - * @return string|null + * @return string|null The form handler result or null. */ function media_upload_library() { $errors = array(); @@ -1148,7 +1148,7 @@ function media_upload_library() { * * @param WP_Post $post * @param string $checked - * @return string + * @return string HTML for the image alignment radio buttons. */ function image_align_input_fields( $post, $checked = '' ) { @@ -1186,7 +1186,7 @@ function image_align_input_fields( $post, $checked = '' ) { * * @param WP_Post $post * @param bool|string $check - * @return array + * @return array An array of data for the image size input fields. */ function image_size_input_fields( $post, $check = '' ) { /** @@ -1264,7 +1264,7 @@ function image_size_input_fields( $post, $check = '' ) { * * @param WP_Post $post * @param string $url_type - * @return string + * @return string HTML markup for the link URL buttons. */ function image_link_input_fields( $post, $url_type = '' ) { @@ -1313,7 +1313,7 @@ function wp_caption_input_textarea( $edit_post ) { * * @param array $form_fields * @param object $post - * @return array + * @return array> The attachment form fields. */ function image_attachment_fields_to_edit( $form_fields, $post ) { return $form_fields; @@ -1355,7 +1355,7 @@ function media_post_single_attachment_fields_to_edit( $form_fields, $post ) { * @param string $html * @param int $attachment_id * @param array $attachment - * @return string + * @return string HTML markup for the media element. */ function image_media_send_to_editor( $html, $attachment_id, $attachment ) { $post = get_post( $attachment_id ); @@ -1380,7 +1380,7 @@ function image_media_send_to_editor( $html, $attachment_id, $attachment ) { * * @param WP_Post $post * @param array $errors - * @return array + * @return array> The attachment fields. */ function get_attachment_fields_to_edit( $post, $errors = null ) { if ( is_int( $post ) ) { @@ -1865,11 +1865,13 @@ function get_media_item( $attachment_id, $args = null ) { } /** + * Retrieves the media markup for an attachment. + * * @since 3.5.0 * * @param int $attachment_id * @param array $args - * @return array + * @return array An array containing the media item and its metadata. */ function get_compat_media_markup( $attachment_id, $args = null ) { $post = get_post( $attachment_id ); diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index d5a861638a2e7..d3a93a17b48e2 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -819,7 +819,7 @@ function set_screen_options() { * @since 2.8.0 * * @param string $filename The file path to the configuration file. - * @return bool + * @return bool Whether the rule exists. */ function iis7_rewrite_rule_exists( $filename ) { if ( ! file_exists( $filename ) ) { @@ -852,7 +852,7 @@ function iis7_rewrite_rule_exists( $filename ) { * @since 2.8.0 * * @param string $filename Name of the configuration file. - * @return bool + * @return bool Whether the rule was deleted. */ function iis7_delete_rewrite_rule( $filename ) { // If configuration file does not exist then rules also do not exist, so there is nothing to delete. @@ -892,7 +892,7 @@ function iis7_delete_rewrite_rule( $filename ) { * * @param string $filename The file path to the configuration file. * @param string $rewrite_rule The XML fragment with URL Rewrite rule. - * @return bool + * @return bool Whether the rule was added. */ function iis7_add_rewrite_rule( $filename, $rewrite_rule ) { if ( ! class_exists( 'DOMDocument', false ) ) { diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php index 0fd9b3c79a5ff..f5aeea835bd12 100644 --- a/src/wp-admin/includes/update.php +++ b/src/wp-admin/includes/update.php @@ -34,7 +34,7 @@ function get_preferred_from_update_core() { * * @param array $options Set $options['dismissed'] to true to show dismissed upgrades too, * set $options['available'] to false to skip not-dismissed updates. - * @return array|false Array of the update objects on success, false on failure. + * @return array|false Array of the update objects on success, false on failure. */ function get_core_updates( $options = array() ) { $options = array_merge( @@ -126,7 +126,7 @@ function find_core_auto_update() { * * @param string $version Version string to query. * @param string $locale Locale to query. - * @return array|false An array of checksums on success, false on failure. + * @return array|false An array of checksums on success, false on failure. */ function get_core_checksums( $version, $locale ) { $http_url = 'http://api.wordpress.org/core/checksums/1.0/?' . http_build_query( compact( 'version', 'locale' ), '', '&' ); @@ -178,7 +178,7 @@ function get_core_checksums( $version, $locale ) { * @since 2.7.0 * * @param object $update - * @return bool + * @return bool True if the option was updated, false otherwise. */ function dismiss_core_update( $update ) { $dismissed = get_site_option( 'dismissed_update_core' ); @@ -194,7 +194,7 @@ function dismiss_core_update( $update ) { * * @param string $version * @param string $locale - * @return bool + * @return bool True if the option was updated, false otherwise. */ function undismiss_core_update( $version, $locale ) { $dismissed = get_site_option( 'dismissed_update_core' ); @@ -242,7 +242,7 @@ function find_core_update( $version, $locale ) { * @since 2.3.0 * * @param string $msg - * @return string + * @return string The core update footer message. */ function core_update_footer( $msg = '' ) { if ( ! current_user_can( 'update_core' ) ) { @@ -297,7 +297,7 @@ function core_update_footer( $msg = '' ) { * @since 2.3.0 * * @global string $pagenow The filename of the current screen. - * @return void|false + * @return void|false Void on success, false if the update nag should not be displayed. */ function update_nag() { global $pagenow; @@ -401,7 +401,7 @@ function update_right_now_message() { * * @since 2.9.0 * - * @return object[] + * @return array Array of plugin objects with available updates. */ function get_plugin_updates() { $all_plugins = get_plugins(); @@ -446,7 +446,7 @@ function wp_plugin_update_rows() { * * @param string $file Plugin basename. * @param array $plugin_data Plugin information. - * @return void|false + * @return void|false Void on success, false if the plugin update is not available. */ function wp_plugin_update_row( $file, $plugin_data ) { $current = get_site_transient( 'update_plugins' ); @@ -624,7 +624,7 @@ function wp_plugin_update_row( $file, $plugin_data ) { * * @since 2.9.0 * - * @return WP_Theme[] + * @return array Array of theme objects with available updates. */ function get_theme_updates() { $current = get_site_transient( 'update_themes' ); @@ -671,7 +671,7 @@ function wp_theme_update_rows() { * * @param string $theme_key Theme stylesheet. * @param WP_Theme $theme Theme object. - * @return void|false + * @return void|false Void on success, false if the theme update is not available. */ function wp_theme_update_row( $theme_key, $theme ) { $current = get_site_transient( 'update_themes' ); @@ -848,7 +848,7 @@ function wp_theme_update_row( $theme_key, $theme ) { * * @global int $upgrading * - * @return void|false + * @return void|false Void on success, false if the maintenance nag should not be displayed. */ function maintenance_nag() { global $upgrading; diff --git a/src/wp-includes/class-wp-http-ixr-client.php b/src/wp-includes/class-wp-http-ixr-client.php index 41d31ca9d14ae..71875d550fc2f 100644 --- a/src/wp-includes/class-wp-http-ixr-client.php +++ b/src/wp-includes/class-wp-http-ixr-client.php @@ -51,7 +51,7 @@ public function __construct( $server, $path = false, $port = false, $timeout = 1 * @since 5.5.0 Formalized the existing `...$args` parameter by adding it * to the function signature. * - * @return bool + * @return bool True if the request succeeded, false otherwise. */ public function query( ...$args ) { $method = array_shift( $args ); diff --git a/src/wp-includes/vars.php b/src/wp-includes/vars.php index 22496330c33ff..38e75781e17ec 100644 --- a/src/wp-includes/vars.php +++ b/src/wp-includes/vars.php @@ -158,7 +158,7 @@ * @since 3.4.0 * @since 6.4.0 Added checking for the Sec-CH-UA-Mobile request header. * - * @return bool + * @return bool Whether the request is from a mobile device. */ function wp_is_mobile() { if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) { From 3fda725bc4c4d1962714c87e944f29cd595a4524 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sat, 7 Feb 2026 23:27:11 +0000 Subject: [PATCH 005/147] Docs: Document the `$wpdb` global in `wp_register_core_abilities()`. Follow-up to [61063], [61069]. Props noruzzaman, huzaifaalmesbah, shailu25, sabernhardt. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61595 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/abilities.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0320df3b9f38a..af88f6fe85b5b 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -39,6 +39,8 @@ function wp_register_core_ability_categories(): void { * * @since 6.9.0 * + * @global wpdb $wpdb WordPress database abstraction object. + * * @return void */ function wp_register_core_abilities(): void { From fc0cdbc2fe41873489dc0125991dda625b961816 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Feb 2026 23:46:18 +0000 Subject: [PATCH 006/147] Menus: Add `item_updated` label for `wp_navigation` post type. This ensures the appropriate "Navigation Menu updated." message is shown in the snackbar after updating a navigation menu in the Site Editor. Without this, a generic "Post updated." message is displayed. Developed in https://github.com/WordPress/wordpress-develop/pull/10882 Follow-up to [58055], [52145], [52069]. Props juanfra. See #61095. Fixes #64611. git-svn-id: https://develop.svn.wordpress.org/trunk@61596 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/post.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 70abcfb1134a8..4382aa003ff62 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -559,6 +559,7 @@ function create_initial_post_types() { 'filter_items_list' => __( 'Filter Navigation Menu list' ), 'items_list_navigation' => __( 'Navigation Menus list navigation' ), 'items_list' => __( 'Navigation Menus list' ), + 'item_updated' => __( 'Navigation Menu updated.' ), ), 'description' => __( 'Navigation menus that can be inserted into your site.' ), 'public' => false, From 8906a00409d5121be38d462e84d49cdaa3995981 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Sat, 7 Feb 2026 23:52:30 +0000 Subject: [PATCH 007/147] Media: Add external icon to alt text links. This link opens in a new tab, and needs a visual indicator to provide an affordance to sighted users that this will happen. Props burtrw, sabernhardt, audrasjb, westonruter, joedolson. Fixes #64374. git-svn-id: https://develop.svn.wordpress.org/trunk@61597 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/common.css | 1 + src/wp-admin/includes/media.php | 2 +- src/wp-includes/css/media-views.css | 7 +++++++ src/wp-includes/media-template.php | 2 +- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index e062a471d7150..70c110d351d0e 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -139,6 +139,7 @@ .screen-reader-text + .dashicons-external { margin-top: -1px; margin-left: 2px; + text-decoration: none; } .screen-reader-shortcut { diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php index 7cbde12d787f1..9b70dc96cda94 100644 --- a/src/wp-admin/includes/media.php +++ b/src/wp-admin/includes/media.php @@ -3245,7 +3245,7 @@ function edit_form_image_editor( $post ) { esc_url( __( 'https://www.w3.org/WAI/tutorials/images/decision-tree/' ) ), 'target="_blank"', sprintf( - ' %s', + ' %s', /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) ) diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 1e519c02eb614..45c6370d178db 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -503,6 +503,13 @@ float: right; } +.attachment-details .setting + .description .dashicons { + width: 16px; + height: 16px; + font-size: 16px; + margin-top: 0px; +} + .media-sidebar .setting .value, .attachment-details .setting .value, .attachment-details .setting + .description { diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 21b66d088caa4..f8f5dd7cbaff4 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -163,7 +163,7 @@ function wp_print_media_templates() { esc_url( __( 'https://www.w3.org/WAI/tutorials/images/decision-tree/' ) ), 'target="_blank"', sprintf( - ' %s', + ' %s', /* translators: Hidden accessibility text. */ __( '(opens in a new tab)' ) ) From 903187e3ef066323824f28306a6ab926ff606481 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Sun, 8 Feb 2026 01:10:32 +0000 Subject: [PATCH 008/147] Plugins/Themes: Increase size of drop region for uploads. The native drop area surface is the `input[type="file"]` element, which is quite small on plugin and theme upload screens. A larger drop area makes it easier for users to successfully drag their file over the region. Modify the CSS so that the file input occupies the full visual space. Props ibrahimriaz, ronya4927, huzaifaalmesbah, noruzzaman, r1k0, nikunj8866, joedolson. Fixes #64065. git-svn-id: https://develop.svn.wordpress.org/trunk@61598 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/themes.css | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/css/themes.css b/src/wp-admin/css/themes.css index ea62a09cf1ed1..3b8eb28ea57b0 100644 --- a/src/wp-admin/css/themes.css +++ b/src/wp-admin/css/themes.css @@ -1086,9 +1086,7 @@ body.folded .theme-browser ~ .theme-overlay .theme-wrap { .upload-theme .wp-upload-form, .upload-plugin .wp-upload-form { - background: #f6f7f7; - border: 1px solid #c3c4c7; - padding: 30px; + position: relative; margin: 30px auto; display: inline-flex; justify-content: space-between; @@ -1097,7 +1095,16 @@ body.folded .theme-browser ~ .theme-overlay .theme-wrap { .upload-theme .wp-upload-form input[type="file"], .upload-plugin .wp-upload-form input[type="file"] { - margin-right: 10px; + background: #f6f7f7; + border: 1px solid #c3c4c7; + margin: 0; + padding: 30px 128px 30px 30px; +} + +.upload-plugin .wp-upload-form input[type=submit], +.upload-theme .wp-upload-form input[type=submit] { + position: absolute; + right: 30px; } .upload-theme .install-help, @@ -1131,6 +1138,7 @@ p.no-themes-local { } @media only screen and (max-width: 1120px) { + .upload-plugin .wp-upload-form, .upload-theme .wp-upload-form { margin: 20px 0; max-width: 100%; @@ -2015,12 +2023,22 @@ body.full-overlay-active { padding-bottom: 4px; } - .upload-theme .wp-upload-form, - .upload-plugin .wp-upload-form { - display: block; + .upload-plugin .wp-upload-form, + .upload-theme .wp-upload-form { + width: 100%; } - :is(.upload-theme, .upload-plugin) .wp-upload-form input[type="submit"] { - margin-top: 10px; + .upload-plugin .wp-upload-form input[type=file], + .upload-theme .wp-upload-form input[type=file] { + padding: 30px 30px 80px; + width: 100%; + } + + :is(.upload-theme, .upload-plugin) .wp-upload-form input[type="submit"].button { + right: unset; + left: 50%; + transform: translateX(-50%) !important; + top: calc( 1.4em + 42px ); /* Line height of control + gap + top padding. */ + margin: 10px 0 0; } } From b2486b842b0e8b6369c3a12ae52aac7aa8fc62e6 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Sun, 8 Feb 2026 17:22:29 +0000 Subject: [PATCH 009/147] Docs: Remove `@return void` from Abilities API DocBlocks. Per the documentation standards, it should not be used outside of the default bundled themes. Follow-up to [55725], [56943], [61063], [61069]. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61599 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/abilities.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index af88f6fe85b5b..4c6db1ed830e0 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -13,8 +13,6 @@ * Registers the core ability categories. * * @since 6.9.0 - * - * @return void */ function wp_register_core_ability_categories(): void { wp_register_ability_category( @@ -40,8 +38,6 @@ function wp_register_core_ability_categories(): void { * @since 6.9.0 * * @global wpdb $wpdb WordPress database abstraction object. - * - * @return void */ function wp_register_core_abilities(): void { $category_site = 'site'; From bdfd4332904d557e553cc6fc6a55840f92404376 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 16:43:35 +0000 Subject: [PATCH 010/147] Abilities API: Add core/get-settings ability. Introduce a new `core/get-settings` ability to the WordPress Abilities API that dynamically discovers and exposes WordPress settings. Settings with `show_in_abilities` enabled are exposed through this ability. Props jorgefilipecosta, jason_the_adams, mukeshpanchal27, justlevine, ovidiu-galatan. Fixes #64605. git-svn-id: https://develop.svn.wordpress.org/trunk@61600 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/abilities.php | 4 + .../abilities/class-wp-settings-abilities.php | 343 +++++++++++++++++ src/wp-includes/option.php | 167 ++++---- .../wpRestAbilitiesSettingsController.php | 356 ++++++++++++++++++ 4 files changed, 797 insertions(+), 73 deletions(-) create mode 100644 src/wp-includes/abilities/class-wp-settings-abilities.php create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4c6db1ed830e0..132ca6fc673a3 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-settings-abilities.php'; + /** * Registers the core ability categories. * @@ -257,4 +259,6 @@ function wp_register_core_abilities(): void { ), ) ); + + WP_Settings_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php new file mode 100644 index 0000000000000..5af7fa48450ee --- /dev/null +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -0,0 +1,343 @@ + args for allowed settings. + */ + private static function get_allowed_settings(): array { + $settings = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + if ( ! empty( $args['show_in_abilities'] ) ) { + $settings[ $option_name ] = $args; + } + } + + return $settings; + } + + /** + * Gets unique setting groups that have show_in_abilities enabled. + * + * @since 7.0.0 + * + * @return string[] List of unique group names. + */ + private static function get_available_groups(): array { + $groups = array(); + + foreach ( self::get_allowed_settings() as $args ) { + $group = $args['group'] ?? 'general'; + if ( ! in_array( $group, $groups, true ) ) { + $groups[] = $group; + } + } + + sort( $groups ); + + return $groups; + } + + /** + * Gets unique setting slugs that have show_in_abilities enabled. + * + * @since 7.0.0 + * + * @return string[] List of unique setting slugs. + */ + private static function get_available_slugs(): array { + $slugs = array(); + + foreach ( self::get_allowed_settings() as $option_name => $args ) { + $slugs[] = $option_name; + } + + sort( $slugs ); + + return $slugs; + } + + /** + * Builds a rich output schema from registered settings metadata. + * + * Creates a JSON Schema that documents each setting group and its settings + * with their types, titles, descriptions, defaults, and any additional + * schema properties from show_in_rest. + * + * @since 7.0.0 + * + * @return array JSON Schema for the output. + */ + private static function build_output_schema(): array { + $group_properties = array(); + + foreach ( self::get_allowed_settings() as $option_name => $args ) { + $group = $args['group'] ?? 'general'; + + $setting_schema = array( + 'type' => $args['type'] ?? 'string', + ); + + if ( ! empty( $args['label'] ) ) { + $setting_schema['title'] = $args['label']; + } + + if ( ! empty( $args['description'] ) ) { + $setting_schema['description'] = $args['description']; + } elseif ( ! empty( $args['label'] ) ) { + $setting_schema['description'] = $args['label']; + } + + if ( ! isset( $group_properties[ $group ] ) ) { + $group_properties[ $group ] = array( + 'type' => 'object', + 'properties' => array(), + 'additionalProperties' => false, + ); + } + + $group_properties[ $group ]['properties'][ $option_name ] = $setting_schema; + } + + ksort( $group_properties ); + + return array( + 'type' => 'object', + 'description' => __( 'Settings grouped by registration group. Each group contains settings with their current values.' ), + 'properties' => $group_properties, + 'additionalProperties' => false, + ); + } + + /** + * Registers the core/get-settings ability. + * + * @since 7.0.0 + * + * @return void + */ + private static function register_get_settings(): void { + wp_register_ability( + 'core/get-settings', + array( + 'label' => __( 'Get Settings' ), + 'description' => __( 'Returns registered WordPress settings grouped by their registration group. Returns key-value pairs per setting.' ), + 'category' => 'site', + 'input_schema' => array( + 'default' => (object) array(), + 'oneOf' => array( + // Branch 1: No filter (empty object). + array( + 'type' => 'object', + 'additionalProperties' => false, + 'maxProperties' => 0, + ), + // Branch 2: Filter by group only. + array( + 'type' => 'object', + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'description' => __( 'Filter settings by group name.' ), + 'enum' => self::$available_groups, + ), + ), + 'required' => array( 'group' ), + 'additionalProperties' => false, + ), + // Branch 3: Filter by slugs only. + array( + 'type' => 'object', + 'properties' => array( + 'slugs' => array( + 'type' => 'array', + 'description' => __( 'Filter settings by specific setting slugs.' ), + 'items' => array( + 'type' => 'string', + 'enum' => self::$available_slugs, + ), + ), + ), + 'required' => array( 'slugs' ), + 'additionalProperties' => false, + ), + ), + ), + 'output_schema' => self::$output_schema, + 'execute_callback' => array( __CLASS__, 'execute_get_settings' ), + 'permission_callback' => array( __CLASS__, 'check_manage_options' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback for settings abilities. + * + * @since 7.0.0 + * + * @return bool True if the current user can manage options, false otherwise. + */ + public static function check_manage_options(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Execute callback for core/get-settings ability. + * + * Retrieves all registered settings that are exposed through the Abilities API, + * grouped by their registration group. + * + * @since 7.0.0 + * + * @param array $input { + * Optional. Input parameters. + * + * @type string $group Optional. Filter settings by group name. Cannot be used with slugs. + * @type string[] $slugs Optional. Filter settings by specific setting slugs. Cannot be used with group. + * } + * @return array Settings grouped by registration group. + */ + public static function execute_get_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + $filter_group = ! empty( $input['group'] ) ? $input['group'] : null; + $filter_slugs = ! empty( $input['slugs'] ) ? $input['slugs'] : null; + + $settings_by_group = array(); + + foreach ( self::get_allowed_settings() as $option_name => $args ) { + $group = $args['group'] ?? 'general'; + + if ( $filter_group && $group !== $filter_group ) { + continue; + } + + if ( $filter_slugs && ! in_array( $option_name, $filter_slugs, true ) ) { + continue; + } + + $default = $args['default'] ?? null; + + $value = get_option( $option_name, $default ); + $value = self::cast_value( $value, $args['type'] ?? 'string' ); + + if ( ! isset( $settings_by_group[ $group ] ) ) { + $settings_by_group[ $group ] = array(); + } + + $settings_by_group[ $group ][ $option_name ] = $value; + } + + ksort( $settings_by_group ); + + return $settings_by_group; + } + + /** + * Casts a value to the appropriate type based on the setting's registered type. + * + * @since 7.0.0 + * + * @param mixed $value The value to cast. + * @param string $type The registered type (string, boolean, integer, number, array, object). + * @return string|bool|int|float|array The cast value. + */ + private static function cast_value( $value, string $type ) { + switch ( $type ) { + case 'boolean': + return (bool) $value; + case 'integer': + return (int) $value; + case 'number': + return (float) $value; + case 'array': + case 'object': + return is_array( $value ) ? $value : array(); + case 'string': + default: + return (string) $value; + } + } +} diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 7979c119a986f..8a9a2c3c89ece 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2743,12 +2743,13 @@ function register_initial_settings() { 'general', 'blogname', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'title', ), - 'type' => 'string', - 'label' => __( 'Title' ), - 'description' => __( 'Site title.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Title' ), + 'description' => __( 'Site title.' ), ) ); @@ -2756,12 +2757,13 @@ function register_initial_settings() { 'general', 'blogdescription', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'description', ), - 'type' => 'string', - 'label' => __( 'Tagline' ), - 'description' => __( 'Site tagline.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Tagline' ), + 'description' => __( 'Site tagline.' ), ) ); @@ -2770,14 +2772,15 @@ function register_initial_settings() { 'general', 'siteurl', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'url', 'schema' => array( 'format' => 'uri', ), ), - 'type' => 'string', - 'description' => __( 'Site URL.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Site URL.' ), ) ); } @@ -2787,14 +2790,15 @@ function register_initial_settings() { 'general', 'admin_email', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'email', 'schema' => array( 'format' => 'email', ), ), - 'type' => 'string', - 'description' => __( 'This address is used for admin purposes, like new user notification.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'This address is used for admin purposes, like new user notification.' ), ) ); } @@ -2803,11 +2807,12 @@ function register_initial_settings() { 'general', 'timezone_string', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'timezone', ), - 'type' => 'string', - 'description' => __( 'A city in the same timezone as you.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A city in the same timezone as you.' ), ) ); @@ -2815,9 +2820,10 @@ function register_initial_settings() { 'general', 'date_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A date format for all date strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A date format for all date strings.' ), ) ); @@ -2825,9 +2831,10 @@ function register_initial_settings() { 'general', 'time_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A time format for all time strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A time format for all time strings.' ), ) ); @@ -2835,9 +2842,10 @@ function register_initial_settings() { 'general', 'start_of_week', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'A day number of the week that the week should start on.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'A day number of the week that the week should start on.' ), ) ); @@ -2845,12 +2853,13 @@ function register_initial_settings() { 'general', 'WPLANG', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'language', ), - 'type' => 'string', - 'description' => __( 'WordPress locale code.' ), - 'default' => 'en_US', + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'WordPress locale code.' ), + 'default' => 'en_US', ) ); @@ -2858,10 +2867,11 @@ function register_initial_settings() { 'writing', 'use_smilies', array( - 'show_in_rest' => true, - 'type' => 'boolean', - 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), - 'default' => true, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'boolean', + 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), + 'default' => true, ) ); @@ -2869,9 +2879,10 @@ function register_initial_settings() { 'writing', 'default_category', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'Default post category.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'Default post category.' ), ) ); @@ -2879,9 +2890,10 @@ function register_initial_settings() { 'writing', 'default_post_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'Default post format.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Default post format.' ), ) ); @@ -2889,11 +2901,12 @@ function register_initial_settings() { 'reading', 'posts_per_page', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Maximum posts per page' ), - 'description' => __( 'Blog pages show at most.' ), - 'default' => 10, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Maximum posts per page' ), + 'description' => __( 'Blog pages show at most.' ), + 'default' => 10, ) ); @@ -2901,10 +2914,11 @@ function register_initial_settings() { 'reading', 'show_on_front', array( - 'show_in_rest' => true, - 'type' => 'string', - 'label' => __( 'Show on front' ), - 'description' => __( 'What to show on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Show on front' ), + 'description' => __( 'What to show on the front page' ), ) ); @@ -2912,10 +2926,11 @@ function register_initial_settings() { 'reading', 'page_on_front', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Page on front' ), - 'description' => __( 'The ID of the page that should be displayed on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Page on front' ), + 'description' => __( 'The ID of the page that should be displayed on the front page' ), ) ); @@ -2923,9 +2938,10 @@ function register_initial_settings() { 'reading', 'page_for_posts', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'The ID of the page that should display the latest posts' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the page that should display the latest posts' ), ) ); @@ -2933,13 +2949,14 @@ function register_initial_settings() { 'discussion', 'default_ping_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), ) ); @@ -2947,14 +2964,15 @@ function register_initial_settings() { 'discussion', 'default_comment_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'label' => __( 'Allow comments on new posts' ), - 'description' => __( 'Allow people to submit comments on new posts.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Allow comments on new posts' ), + 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); } @@ -2985,10 +3003,12 @@ function register_initial_settings() { * @type string $label A label of the data attached to this setting. * @type string $description A description of the data attached to this setting. * @type callable $sanitize_callback A callback function that sanitizes the option's value. - * @type bool|array $show_in_rest Whether data associated with this setting should be included in the REST API. - * When registering complex settings, this argument may optionally be an - * array with a 'schema' key. - * @type mixed $default Default value when calling `get_option()`. + * @type bool|array $show_in_rest Whether data associated with this setting should be included in the REST API. + * When registering complex settings, this argument may optionally be an + * array with a 'schema' key. + * @type bool $show_in_abilities Whether this setting should be exposed through the Abilities API. + * Default false. + * @type mixed $default Default value when calling `get_option()`. * } */ function register_setting( $option_group, $option_name, $args = array() ) { @@ -3001,12 +3021,13 @@ function register_setting( $option_group, $option_name, $args = array() ) { $GLOBALS['new_whitelist_options'] = &$new_allowed_options; $defaults = array( - 'type' => 'string', - 'group' => $option_group, - 'label' => '', - 'description' => '', - 'sanitize_callback' => null, - 'show_in_rest' => false, + 'type' => 'string', + 'group' => $option_group, + 'label' => '', + 'description' => '', + 'sanitize_callback' => null, + 'show_in_rest' => false, + 'show_in_abilities' => false, ); // Back-compat: old sanitize callback is added. diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php new file mode 100644 index 0000000000000..198c0c3b8bc69 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -0,0 +1,356 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Register initial settings first so abilities can build schemas. + register_initial_settings(); + + // Ensure core abilities are registered for these tests. + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Re-add the unhook functions for subsequent tests. + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + // Remove the core abilities and their categories. + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + wp_set_current_user( self::$admin_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Tests that unauthenticated users cannot access the get-settings ability. + * + * @ticket 64605 + */ + public function test_core_get_settings_requires_authentication(): void { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Tests that subscribers cannot access the get-settings ability. + * + * @ticket 64605 + */ + public function test_core_get_settings_requires_manage_options_capability(): void { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Tests that administrators can access the get-settings ability. + * + * @ticket 64605 + */ + public function test_core_get_settings_allows_administrators(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Tests that the get-settings ability returns settings grouped by registration group. + * + * @ticket 64605 + */ + public function test_core_get_settings_returns_grouped_settings(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'general', $data ); + $this->assertArrayHasKey( 'blogname', $data['general'] ); + $this->assertArrayHasKey( 'blogdescription', $data['general'] ); + } + + /** + * Tests that the get-settings ability can filter by group. + * + * @ticket 64605 + */ + public function test_core_get_settings_filters_by_group(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'group' => 'general', + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data ); + $this->assertArrayHasKey( 'general', $data ); + } + + /** + * Tests that the get-settings ability can filter by specific slugs. + * + * @ticket 64605 + */ + public function test_core_get_settings_filters_by_slugs(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'blogname', 'blogdescription' ), + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'general', $data ); + $this->assertCount( 2, $data['general'] ); + $this->assertArrayHasKey( 'blogname', $data['general'] ); + $this->assertArrayHasKey( 'blogdescription', $data['general'] ); + } + + /** + * Tests that settings without show_in_abilities are excluded. + * + * @ticket 64605 + */ + public function test_core_get_settings_excludes_settings_without_show_in_abilities(): void { + register_setting( + 'general', + 'test_setting_excluded', + array( + 'type' => 'string', + 'default' => 'test_value', + 'show_in_abilities' => false, + ) + ); + update_option( 'test_setting_excluded', 'test_value' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'test_setting_excluded', $data['general'] ?? array() ); + + unregister_setting( 'general', 'test_setting_excluded' ); + delete_option( 'test_setting_excluded' ); + } + + /** + * Tests that core settings with show_in_abilities are included. + * + * @ticket 64605 + */ + public function test_core_get_settings_includes_settings_with_show_in_abilities(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // blogname has show_in_abilities => true in register_initial_settings(). + $this->assertArrayHasKey( 'general', $data ); + $this->assertArrayHasKey( 'blogname', $data['general'] ); + + // use_smilies has show_in_abilities => true. + $this->assertArrayHasKey( 'writing', $data ); + $this->assertArrayHasKey( 'use_smilies', $data['writing'] ); + } + + /** + * Tests that boolean settings are cast to actual booleans. + * + * @ticket 64605 + */ + public function test_core_get_settings_casts_boolean_values(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'use_smilies' ), + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'writing', $data ); + $this->assertArrayHasKey( 'use_smilies', $data['writing'] ); + $this->assertIsBool( $data['writing']['use_smilies'] ); + } + + /** + * Tests that integer settings are cast to actual integers. + * + * @ticket 64605 + */ + public function test_core_get_settings_casts_integer_values(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'start_of_week' ), + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'general', $data ); + $this->assertArrayHasKey( 'start_of_week', $data['general'] ); + $this->assertIsInt( $data['general']['start_of_week'] ); + } + + /** + * Tests that the get-settings ability requires GET method (read-only). + * + * @ticket 64605 + */ + public function test_core_get_settings_requires_get_method(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + } + + /** + * Tests that the get-settings ability returns correct values. + * + * @ticket 64605 + */ + public function test_core_get_settings_returns_correct_values(): void { + update_option( 'blogname', 'Test Site Name' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'blogname' ), + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertSame( 'Test Site Name', $data['general']['blogname'] ); + } +} From edbdcbe35e7ce19cd9cde1166b0b6e2d52729570 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Mon, 9 Feb 2026 16:53:07 +0000 Subject: [PATCH 011/147] Filesystem API: Avoid `chmod()` warnings if the permissions already match. This prevents spurious warnings from `WP_Filesystem_Direct::chmod()` when the web process doesn't have ownership over the files, and thus cannot change the permissions, even if only to reassert the existing mode. Follow-up to [6779], [11667], [12998]. Props redsweater, mukesh27, SergeyBiryukov. Fixes #64610. git-svn-id: https://develop.svn.wordpress.org/trunk@61601 602fd350-edb4-49c9-b593-d223f7449a82 --- .../includes/class-wp-filesystem-direct.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index ed22a821a14b0..a4b197c15229f 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -170,6 +170,22 @@ public function chmod( $file, $mode = false, $recursive = false ) { } if ( ! $recursive || ! $this->is_dir( $file ) ) { + $current_mode = fileperms( $file ) & 0777 | 0644; + + /* + * fileperms() populates the stat cache, so have to clear it + * to maintain parity with the previous behavior. + */ + clearstatcache( true, $file ); + + /* + * Avoid calling chmod() if the requested mode is already set, + * to prevent throwing a warning when we aren't the owner. + */ + if ( $current_mode === $mode ) { + return true; + } + return chmod( $file, $mode ); } From 55227eb2d58ffa1ca3d90a061d1611932498243b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 16:59:52 +0000 Subject: [PATCH 012/147] Abilities API: Allow nested namespace ability names (2-4 segments). Expand ability name validation from exactly 2 segments (`namespace/ability`) to 2-4 segments, enabling names like `my-plugin/resource/find` and `my-plugin/resource/sub/find`. This allows plugins to organize abilities into logical resource groups. The validation regex changes from `/^[a-z0-9-]+\/[a-z0-9-]+$/` to `/^[a-z0-9-]+(?:\/[a-z0-9-]+){1,3}$/`, which accepts the first segment plus 1-3 additional slash-delimited segments. Updates the validation regex, error messages, docblocks, and adds corresponding unit and REST API tests. Props jorgefilipecosta, justlevine, jorbin. Fixes #64596. git-svn-id: https://develop.svn.wordpress.org/trunk@61602 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/abilities-api.php | 14 ++-- .../class-wp-abilities-registry.php | 9 ++- .../abilities-api/class-wp-ability.php | 4 +- .../abilities-api/wpAbilitiesRegistry.php | 68 +++++++++++++++++++ .../wpRestAbilitiesV1RunController.php | 62 +++++++++++++++++ 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 73ba658f3f10d..835bd535d2487 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -132,7 +132,8 @@ * * Ability names must follow these rules: * - * - Include a namespace prefix (e.g., `my-plugin/my-ability`). + * - Contain 2 to 4 segments separated by forward slashes + * (e.g., `my-plugin/my-ability`, `my-plugin/resource/find`, `my-plugin/resource/sub/find`). * - Use only lowercase alphanumeric characters, dashes, and forward slashes. * - Use descriptive, action-oriented names (e.g., `process-payment`, `generate-report`). * @@ -225,9 +226,8 @@ * @see wp_register_ability_category() * @see wp_unregister_ability() * - * @param string $name The name of the ability. Must be a namespaced string containing - * a prefix, e.g., `my-plugin/my-ability`. Can only contain lowercase - * alphanumeric characters, dashes, and forward slashes. + * @param string $name The name of the ability. Must be the fully-namespaced + * string identifier, e.g. `my-plugin/my-ability` or `my-plugin/resource/my-ability`. * @param array $args { * An associative array of arguments for configuring the ability. * @@ -318,7 +318,7 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { * @see wp_register_ability() * * @param string $name The name of the ability to unregister, including namespace prefix - * (e.g., 'my-plugin/my-ability'). + * (e.g., 'my-plugin/my-ability' or 'my-plugin/resource/find'). * @return WP_Ability|null The unregistered ability instance on success, `null` on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { @@ -351,7 +351,7 @@ function wp_unregister_ability( string $name ): ?WP_Ability { * @see wp_get_ability() * * @param string $name The name of the ability to check, including namespace prefix - * (e.g., 'my-plugin/my-ability'). + * (e.g., 'my-plugin/my-ability' or 'my-plugin/resource/find'). * @return bool `true` if the ability is registered, `false` otherwise. */ function wp_has_ability( string $name ): bool { @@ -383,7 +383,7 @@ function wp_has_ability( string $name ): bool { * @see wp_has_ability() * * @param string $name The name of the ability, including namespace prefix - * (e.g., 'my-plugin/my-ability'). + * (e.g., 'my-plugin/my-ability' or 'my-plugin/resource/find'). * @return WP_Ability|null The registered ability instance, or `null` if not registered. */ function wp_get_ability( string $name ): ?WP_Ability { diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index ecd6dc2785e70..758dd2c2524df 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -43,9 +43,8 @@ final class WP_Abilities_Registry { * * @see wp_register_ability() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. + * @param string $name The name of the ability. Must be the fully-namespaced + * string identifier, e.g. `my-plugin/my-ability` or `my-plugin/resource/my-ability`. * @param array $args { * An associative array of arguments for the ability. * @@ -78,11 +77,11 @@ final class WP_Abilities_Registry { * @return WP_Ability|null The registered ability instance on success, null on failure. */ public function register( string $name, array $args ): ?WP_Ability { - if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { + if ( ! preg_match( '/^[a-z0-9-]+(?:\/[a-z0-9-]+){1,3}$/', $name ) ) { _doing_it_wrong( __METHOD__, __( - 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' + 'Ability name must contain 2 to 4 segments separated by forward slashes, e.g. "my-plugin/my-ability" or "my-plugin/resource/my-ability". It can only contain lowercase alphanumeric characters, dashes, and forward slashes.' ), '6.9.0' ); diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 967f1641156b0..bdcb8c0bd017a 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -52,7 +52,7 @@ class WP_Ability { /** * The name of the ability, with its namespace. - * Example: `my-plugin/my-ability`. + * Examples: `my-plugin/my-ability`, `my-plugin/resource/find`. * * @since 6.9.0 * @var string @@ -340,7 +340,7 @@ protected function prepare_properties( array $args ): array { /** * Retrieves the name of the ability, with its namespace. - * Example: `my-plugin/my-ability`. + * Examples: `my-plugin/my-ability`, `my-plugin/resource/find`. * * @since 6.9.0 * diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 32479d69e2f8c..b9cc58279c118 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -136,6 +136,74 @@ public function test_register_invalid_uppercase_characters_in_name() { $this->assertNull( $result ); } + /** + * Should accept ability name with 3 segments (2 slashes). + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + */ + public function test_register_valid_name_with_three_segments() { + $result = $this->registry->register( 'test/sub/add-numbers', self::$test_ability_args ); + $this->assertInstanceOf( WP_Ability::class, $result ); + $this->assertSame( 'test/sub/add-numbers', $result->get_name() ); + } + + /** + * Should accept ability name with 4 segments (3 slashes). + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + */ + public function test_register_valid_name_with_four_segments() { + $result = $this->registry->register( 'test/sub/deep/add-numbers', self::$test_ability_args ); + $this->assertInstanceOf( WP_Ability::class, $result ); + $this->assertSame( 'test/sub/deep/add-numbers', $result->get_name() ); + } + + /** + * Should reject ability name with 5 segments (exceeds maximum of 4). + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_with_five_segments() { + $result = $this->registry->register( 'test/a/b/c/too-deep', self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with empty segments (double slashes). + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_with_empty_segment() { + $result = $this->registry->register( 'test//add-numbers', self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with trailing slash. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_with_trailing_slash() { + $result = $this->registry->register( 'test/add-numbers/', self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject ability registration without a label. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index bccc30c2f2e94..0c03d72dab8a5 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -379,6 +379,43 @@ private function register_test_abilities(): void { ) ); + // Ability with nested namespace (3 segments). + $this->register_test_ability( + 'test/math/add', + array( + 'label' => 'Nested Add', + 'description' => 'Adds numbers with nested namespace', + 'category' => 'math', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number', + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number', + ), + ), + 'required' => array( 'a', 'b' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => static function ( array $input ) { + return $input['a'] + $input['b']; + }, + 'permission_callback' => static function () { + return current_user_can( 'edit_posts' ); + }, + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + // Read-only ability for query params testing. $this->register_test_ability( 'test/query-params', @@ -432,6 +469,31 @@ public function test_execute_regular_ability_post(): void { $this->assertEquals( 8, $response->get_data() ); } + /** + * Test executing an ability with a nested namespace (3 segments) via REST. + * + * @ticket 64098 + */ + public function test_execute_nested_namespace_ability(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/math/add/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'a' => 10, + 'b' => 7, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 17, $response->get_data() ); + } + /** * Test executing a read-only ability with GET. * From edd68853d46446c07255e79b9fab4fec563d4bbb Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 17:08:13 +0000 Subject: [PATCH 013/147] Block Supports: Prevent fatal error in `WP_Duotone` when the duotone attribute is an array. Adds type checks to `get_slug_from_attribute()`, `is_preset()`, and `get_all_global_style_block_names()` to handle cases where the duotone attribute is an array of custom colors instead of a preset reference string. This prevents an error when `preg_match()` receives an array instead of a string. Props jorgefilipecosta, westonruter, xavilc. Fixes #64612. git-svn-id: https://develop.svn.wordpress.org/trunk@61603 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-duotone.php | 19 ++++++++++++++++--- .../phpunit/tests/block-supports/duotone.php | 4 ++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-duotone.php b/src/wp-includes/class-wp-duotone.php index 69b56e090c5d4..8666f85a0f8e4 100644 --- a/src/wp-includes/class-wp-duotone.php +++ b/src/wp-includes/class-wp-duotone.php @@ -546,10 +546,14 @@ private static function colord_parse( $input ) { * * @since 6.3.0 * - * @param string $duotone_attr The duotone attribute from a block. - * @return string The slug of the duotone preset or an empty string if no slug is found. + * @param string|string[] $duotone_attr The duotone attribute from a block. + * @return string The slug of the duotone preset or an empty string if no slug is found (including when an array was passed). */ private static function get_slug_from_attribute( $duotone_attr ) { + if ( ! is_string( $duotone_attr ) ) { + return ''; + } + // Uses Branch Reset Groups `(?|…)` to return one capture group. preg_match( '/(?|var:preset\|duotone\|(\S+)|var\(--wp--preset--duotone--(\S+)\))/', $duotone_attr, $matches ); @@ -566,9 +570,13 @@ private static function get_slug_from_attribute( $duotone_attr ) { * @since 6.3.0 * * @param string $duotone_attr The duotone attribute from a block. - * @return bool True if the duotone preset present and valid. + * @param string|string[] $duotone_attr The duotone attribute from a block. */ private static function is_preset( $duotone_attr ) { + if ( ! is_string( $duotone_attr ) ) { + return false; + } + $slug = self::get_slug_from_attribute( $duotone_attr ); $filter_id = self::get_filter_id( $slug ); @@ -1050,6 +1058,11 @@ private static function get_all_global_style_block_names() { continue; } // If it has a duotone filter preset, save the block name and the preset slug. + // Only process if it's a string (preset reference), not an array (custom colors). + if ( ! is_string( $duotone_attr ) ) { + continue; + } + $slug = self::get_slug_from_attribute( $duotone_attr ); if ( $slug && $slug !== $duotone_attr ) { diff --git a/tests/phpunit/tests/block-supports/duotone.php b/tests/phpunit/tests/block-supports/duotone.php index 1f60a8247d4c4..808903a072452 100644 --- a/tests/phpunit/tests/block-supports/duotone.php +++ b/tests/phpunit/tests/block-supports/duotone.php @@ -93,6 +93,8 @@ public function data_get_slug_from_attribute() { 'pipe-slug-no-value' => array( 'var:preset|duotone|', '' ), 'css-var-spaces' => array( 'var(--wp--preset--duotone-- ', '' ), 'pipe-slug-spaces' => array( 'var:preset|duotone| ', '' ), + 'array-of-colors' => array( array( '#000000', '#ffffff' ), '' ), + 'empty-array' => array( array(), '' ), ); } @@ -164,6 +166,8 @@ public function data_is_preset() { 'css-var-invalid-slug-chars' => array( 'var(--wp--preset--duotone--.)', false ), 'css-var-missing-end-parenthesis' => array( 'var(--wp--preset--duotone--blue-orange', false ), 'invalid' => array( 'not a valid attribute', false ), + 'array-of-colors' => array( array( '#000000', '#ffffff' ), false ), + 'empty-array' => array( array(), false ), ); } From 4cad33af1d6b3ababddba904f99bd09494039f88 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Feb 2026 18:42:51 +0000 Subject: [PATCH 014/147] Docs: Improve `@global` annotations in abstract-testcase.php Developed in https://github.com/WordPress/wordpress-develop/pull/10841 Follow-up to [61589], [61584]. Props noruzzaman, mukesh27, westonruter, shailu25, huzaifaalmesbah. See #64224. git-svn-id: https://develop.svn.wordpress.org/trunk@61604 602fd350-edb4-49c9-b593-d223f7449a82 --- tests/phpunit/includes/abstract-testcase.php | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index e4eefabfebf69..3a5d52b0706a9 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -160,10 +160,10 @@ public function wp_hash_password_options( array $options, string $algorithm ): a /** * After a test method runs, resets any state in WordPress the test method might have changed. * - * @global wpdb $wpdb WordPress database abstraction object. - * @global WP_Query $wp_the_query WordPress Query object. - * @global WP_Query $wp_query WordPress Query object. - * @global WP $wp WordPress environment object. + * @global wpdb $wpdb WordPress database abstraction object. + * @global WP_Query $wp_the_query Main WordPress query object. + * @global WP_Query $wp_query WordPress query object. + * @global WP $wp WordPress environment object. */ public function tear_down() { global $wpdb, $wp_the_query, $wp_query, $wp; @@ -370,10 +370,10 @@ protected function reset__SERVER() { * Stores $wp_filter, $wp_actions, $wp_filters, and $wp_current_filter * on a class variable so they can be restored on tear_down() using _restore_hooks(). * - * @global array $wp_filter Stores all of the filters and actions. - * @global array $wp_actions Stores the number of times each action was triggered. - * @global array $wp_filters Stores the number of times each filter was triggered. - * @global array $wp_current_filter Stores the list of current filters with the current one last. + * @global array $wp_filter All of the filters and actions. + * @global array $wp_actions The number of times each action was triggered. + * @global array $wp_filters The number of times each filter was triggered. + * @global array $wp_current_filter The list of current filters with the current one last. */ protected function _backup_hooks() { self::$hooks_saved['wp_filter'] = array(); @@ -393,10 +393,10 @@ protected function _backup_hooks() { * Restores the hook-related globals to their state at set_up() * so that future tests aren't affected by hooks set during this last test. * - * @global array $wp_filter Stores all of the filters and actions. - * @global array $wp_actions Stores the number of times each action was triggered. - * @global array $wp_filters Stores the number of times each filter was triggered. - * @global array $wp_current_filter Stores the list of current filters with the current one last. + * @global array $wp_filter All of the filters and actions. + * @global array $wp_actions The number of times each action was triggered. + * @global array $wp_filters The number of times each filter was triggered. + * @global array $wp_current_filter The list of current filters with the current one last. */ protected function _restore_hooks() { if ( isset( self::$hooks_saved['wp_filter'] ) ) { From f8b22857f4194b05876c5923d19b251b2f9cc8ee Mon Sep 17 00:00:00 2001 From: Ella Van Durpe Date: Mon, 9 Feb 2026 19:47:58 +0000 Subject: [PATCH 015/147] Gutenberg ref update. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates unit tests to account for: - "Dynamically add CSS class to Paragraph block" (https://github.com/WordPress/gutenberg/pull/71207) - New block server-side block registrations. Updates the REST API posts controller's excerpt filter to account for "Post Excerpt Block: Fix length limits for both Editor and Front and fix ellipsis consistency" (https://github.com/WordPress/gutenberg/pull/74140/changes#r2783014013). Developed in https://github.com/WordPress/wordpress-develop/pull/10865. Props ellatrix, scruffian, desrosj. See #64595. --- I've included a log of the Gutenberg changes with the following command: git log --reverse --format="- %s" 7bf80ea84eb8b62eceb1bb3fe82e42163673ca79..59a08c5496008ca88f4b6b86f38838c3612d88c8 | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' | pbcopy - Editor: Cleanup active post as needed (https://github.com/WordPress/gutenberg/pull/74118) - Build: fully resolve import paths in transpiled files (https://github.com/WordPress/gutenberg/pull/73822) - Extensible Site Editor: The Canvas should share the same ThemeProvider as all the surfaces (https://github.com/WordPress/gutenberg/pull/74125) - Add Badge component to UI package (https://github.com/WordPress/gutenberg/pull/73875) - Theme_JSON_Resolver: defensively cover against situations where the post is null (https://github.com/WordPress/gutenberg/pull/74124) - Site Editor: Add extensible site editor experiment (https://github.com/WordPress/gutenberg/pull/74123) - Components: Fix DateTimePicker timezone handling for non-string values (https://github.com/WordPress/gutenberg/pull/73887) - Global Fonts: Convert relative font URLs to absolute theme URLs in font-face styles (https://github.com/WordPress/gutenberg/pull/74115) - Global Fonts: Correctly convert relative font URLs to absolute theme URLs in font-face styles (https://github.com/WordPress/gutenberg/pull/74137) - Add Line Indent support (https://github.com/WordPress/gutenberg/pull/73114) - Update report-flaky-tests action to use CommonJS module format (https://github.com/WordPress/gutenberg/pull/74152) - Media Modal experiment: Always show thumbnail field (https://github.com/WordPress/gutenberg/pull/74147) - Refactor isBlockHidden selector to simplify block support check (https://github.com/WordPress/gutenberg/pull/74151) - Apply `post_type_archive_title` on post type archive title in Breadcrumbs (https://github.com/WordPress/gutenberg/pull/73966) - DataView: update free-composition story (https://github.com/WordPress/gutenberg/pull/74146) - Add checkerboard pattern for background in featured image preview (https://github.com/WordPress/gutenberg/pull/74091) - Fix Post Date Block: Semantic use of `date` tag inside link (https://github.com/WordPress/gutenberg/pull/73788) - Terms Query Block: Fix Max terms for non-hierarchical taxonomies (https://github.com/WordPress/gutenberg/pull/74130) - Fields: Add MediaEdit component (https://github.com/WordPress/gutenberg/pull/73537) - Docs: Enhance documentation for Interactivity API and iAPI Router (https://github.com/WordPress/gutenberg/pull/73766) - DataViews: Add groupBy.showLabel config option to control group header label visibility (https://github.com/WordPress/gutenberg/pull/74161) - Theme_JSON_Resolver: check for `WP_Post` instance (https://github.com/WordPress/gutenberg/pull/74172) - Breadcrumbs: Stabilize block (https://github.com/WordPress/gutenberg/pull/74166) - Menu, CustomSelectControl (v1 & 2): Update animation (https://github.com/WordPress/gutenberg/pull/74111) - Add RTL support for drop caps in paragraph block styles in the editor (https://github.com/WordPress/gutenberg/pull/74058) - Font Library: fix help text position in Upload tab (https://github.com/WordPress/gutenberg/pull/74157) - Media Modal experiment: Tweak padding of the modal for consistency (https://github.com/WordPress/gutenberg/pull/74155) - Block visibility based on screen size: add backend block support (https://github.com/WordPress/gutenberg/pull/73994) - Accordion Header: Fix potential undo trap (https://github.com/WordPress/gutenberg/pull/74182) - Classic Block: Always use modal and display block placeholder (https://github.com/WordPress/gutenberg/pull/74162) - Update ToggleGroupControl visual design (https://github.com/WordPress/gutenberg/pull/74036) - Comment Author Name: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74068) - Query Loop: Hide `change design` or `choose pattern` when is locked (https://github.com/WordPress/gutenberg/pull/74160) - Fix: Prevent `accordion-heading` submitting/sending forms (button `type="button"`) (https://github.com/WordPress/gutenberg/pull/74177) - Button: Improve the label of the button block in list view (https://github.com/WordPress/gutenberg/pull/74163) - Add list view tab to the buttons, list and social icons blocks (https://github.com/WordPress/gutenberg/pull/74120) - improve `resolveSelect` type definition (https://github.com/WordPress/gutenberg/pull/73973) - Add label to MediaEdit component (https://github.com/WordPress/gutenberg/pull/74176) - Update LayoutCard story in DataForm to use card layout (https://github.com/WordPress/gutenberg/pull/73695) - `wordpress/dataviews`: migrate to Stack (https://github.com/WordPress/gutenberg/pull/74174) - `wordpress/dataviews`: reorganize code (https://github.com/WordPress/gutenberg/pull/74188) - Tests: Add unit tests for Button block __experimentalLabel functionality (https://github.com/WordPress/gutenberg/pull/74186) - Add `block_core_breadcrumbs_items` filter to Breadcrumbs allowing to filter final items array (https://github.com/WordPress/gutenberg/pull/74169) - `wordpress/dataviews`: improve stories and tests (https://github.com/WordPress/gutenberg/pull/74192) - Block Card: Make the parent block navigation generic, supports any block with list view support (https://github.com/WordPress/gutenberg/pull/74164) - Accordion: Passthrough 'openByDefault' value via context (https://github.com/WordPress/gutenberg/pull/74191) - Improve DataForm stories (https://github.com/WordPress/gutenberg/pull/74196) - DataViews: display a separate `—` for each level (https://github.com/WordPress/gutenberg/pull/74199) - Build: Support pnpm (https://github.com/WordPress/gutenberg/pull/74194) - Accordion: Remove 'isSelected' attribute (https://github.com/WordPress/gutenberg/pull/74198) - Update package changelogs (https://github.com/WordPress/gutenberg/pull/74202) - Docs: Clarify that `npm publishing` requires team approval during the RC1 launch (https://github.com/WordPress/gutenberg/pull/74204) - Extensible Site Editor: Lift template activation restriction (https://github.com/WordPress/gutenberg/pull/74197) - Block support: Add anchor support for dynamic blocks (https://github.com/WordPress/gutenberg/pull/74183) - Template Activation: Try fixing still flaky test (https://github.com/WordPress/gutenberg/pull/74216) - Build: Fix the default base url used when generating php files (https://github.com/WordPress/gutenberg/pull/74220) - Cleanup the dependencies in the root package.json (https://github.com/WordPress/gutenberg/pull/74212) - Remove outdated vendor prefix properties in CSS (https://github.com/WordPress/gutenberg/pull/74213) - Build: Rename extensible site editor page to avoid conflicts (https://github.com/WordPress/gutenberg/pull/74221) - Menu: Clean up popover wrappers (https://github.com/WordPress/gutenberg/pull/74207) - Use a stable npm version on static checks job (https://github.com/WordPress/gutenberg/pull/74222) - Block Editor: Make TextIndentControl component internal (https://github.com/WordPress/gutenberg/pull/74219) - Image Block: Add content tab and reorganize inspector controls (https://github.com/WordPress/gutenberg/pull/74201) - Extensible Site Editor: Fix the dashboard link (https://github.com/WordPress/gutenberg/pull/74231) - Command Palette: Fix in the font library page and site editor experiment (https://github.com/WordPress/gutenberg/pull/74232) - Block Inspector: Update the design of the style variation to use ToolsPanel (https://github.com/WordPress/gutenberg/pull/74224) - Add block transforms between Verse and Quote blocks (https://github.com/WordPress/gutenberg/pull/73068) - Docs: Fix `Get started with create-block` handbook link (https://github.com/WordPress/gutenberg/pull/74237) - tsconfig: Replace skipDefaultLibCheck with skipLibCheck (https://github.com/WordPress/gutenberg/pull/74239) - Docs: Fix `Gutenberg Release Process` handbook link (https://github.com/WordPress/gutenberg/pull/74240) - Schemas: Add breadcrumbs block schema (https://github.com/WordPress/gutenberg/pull/74227) - Tag Cloud: Use new HtmlRenderer component to remove extra div wrapper (https://github.com/WordPress/gutenberg/pull/74228) - Env: Strip version suffix for non-wp-org zip sources (https://github.com/WordPress/gutenberg/pull/74195) - DataViewsPicker Table Layout: Ensure checkbox column is always 48px wide (https://github.com/WordPress/gutenberg/pull/74181) - Docs: fix broken release process links (https://github.com/WordPress/gutenberg/pull/74250) - Add visibility badge for hidden blocks in the block inspector. (https://github.com/WordPress/gutenberg/pull/74180) - Docs: fix callout notices layout and clarify handbook link usage (https://github.com/WordPress/gutenberg/pull/74252) - Tag Cloud: Make error message prefix text translatable (https://github.com/WordPress/gutenberg/pull/74256) - Block variation transformation: change position and threshold (https://github.com/WordPress/gutenberg/pull/74251) - Tabs: Reset focus styles to avoid visual glitch (https://github.com/WordPress/gutenberg/pull/74225) - PHP-only blocks: use `HtmlRenderer` to ensure fontend & editor consistency (https://github.com/WordPress/gutenberg/pull/74261) - Add new `VisuallyHidden` component (https://github.com/WordPress/gutenberg/pull/74189) - Revert "Add Line Indent support (https://github.com/WordPress/gutenberg/pull/73114)" (https://github.com/WordPress/gutenberg/pull/74266) - Fix typos and improve clarity in documentation across multiple files (https://github.com/WordPress/gutenberg/pull/74270) - Archives Block: Use new HtmlRenderer component to remove extra div wrapper and remove editor styles (https://github.com/WordPress/gutenberg/pull/74255) - disable anchor more block (https://github.com/WordPress/gutenberg/pull/74267) - Comment Content: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74269) - Stylelint: Add design token linting (https://github.com/WordPress/gutenberg/pull/74226) - Storybook: Include design tokens styles automatically (https://github.com/WordPress/gutenberg/pull/73938) - Tabs: Adding border radius styling options (https://github.com/WordPress/gutenberg/pull/74103) - Storybook: Show props from component libraries (https://github.com/WordPress/gutenberg/pull/74279) - Theme: Fix design-tokens.js entrypoint to specify types and CJS variants (https://github.com/WordPress/gutenberg/pull/74129) - Add `Field` primitives (https://github.com/WordPress/gutenberg/pull/74190) - Validated form controls: Add stories for validation in popovers (https://github.com/WordPress/gutenberg/pull/71282) - Theme: Refine typography tokens (https://github.com/WordPress/gutenberg/pull/73931) - Packages: Avoid bumping the major version on prerelease packages (https://github.com/WordPress/gutenberg/pull/74285) - Components: Enhance Notice actions to allow more props like disabled and onClick with url (https://github.com/WordPress/gutenberg/pull/74094) - Update color ramp generation snapshots (https://github.com/WordPress/gutenberg/pull/74281) - Upgrade storybook to v9 (https://github.com/WordPress/gutenberg/pull/74143) - Footnotes Block: Fixing various Code Quality and Coding Standard issues (https://github.com/WordPress/gutenberg/pull/74243) - Fix: menu_order validation to allow zero and negative values (https://github.com/WordPress/gutenberg/pull/74282) - Fix: use WP_Theme_JSON_Gutenberg instead of WP_Theme_JSON class (https://github.com/WordPress/gutenberg/pull/74294) - PHP-only blocks: Generate inspector controls from attributes (https://github.com/WordPress/gutenberg/pull/74102) - Update the copyright license to 2026 (https://github.com/WordPress/gutenberg/pull/74306) - Update browsers list data (https://github.com/WordPress/gutenberg/pull/74312) - Storybook: Fix Sass warnings (https://github.com/WordPress/gutenberg/pull/74298) - Update eslint to 8.57.1 (https://github.com/WordPress/gutenberg/pull/74316) - Update eslint-plugin-storybook to 10.1.11 (https://github.com/WordPress/gutenberg/pull/74317) - Tag Cloud, Archives: Fix sidebar flash when changing settings (https://github.com/WordPress/gutenberg/pull/74291) - Tag Cloud, Archives: Restore missing block wrapper div (https://github.com/WordPress/gutenberg/pull/74321) - RSS Block: Use HtmlRenderer to remove extra div from editor and remove editor styles (https://github.com/WordPress/gutenberg/pull/74272) - Breadcrumbs Block: Use HtmlRenderer to remove extra div from editor (https://github.com/WordPress/gutenberg/pull/74273) - Latest Comments: Remove wrapper div and use HtmlRenderer for dynamic content rendering (https://github.com/WordPress/gutenberg/pull/74277) - DataForm: Fix panel field inaccessible when empty with labelPosition none or top (https://github.com/WordPress/gutenberg/pull/74264) - Storybook: Remove outdated story matchers (https://github.com/WordPress/gutenberg/pull/74299) - UI: Exclude package from `jsdoc/require-param` rule (https://github.com/WordPress/gutenberg/pull/74315) - Calender Block: Use HtmlRenderer to remove extra div from editor (https://github.com/WordPress/gutenberg/pull/74271) - Theme: Include Figma scopes extension in design tokens (https://github.com/WordPress/gutenberg/pull/73897) - UI: Remove redundant renderElement utility (https://github.com/WordPress/gutenberg/pull/74284) - Form Field Blocks: Replace dashicon with SVG icons (https://github.com/WordPress/gutenberg/pull/73996) - ContentOnlyControls: Polish header style (https://github.com/WordPress/gutenberg/pull/74260) - Footnotes: prevent inserting footnotes within a footnotes block (https://github.com/WordPress/gutenberg/pull/74287) - Block visibility based on screen size: basic clientside state (https://github.com/WordPress/gutenberg/pull/74025) - Block Support: Fix horizontal overflow in Manage allowed blocks modal (https://github.com/WordPress/gutenberg/pull/74337) - Block support: Backport anchor support changes in core (https://github.com/WordPress/gutenberg/pull/74341) - Dynamically add CSS class to Paragraph block (https://github.com/WordPress/gutenberg/pull/71207) - Test: Update URLs in tests to use example.org instead of test.com (https://github.com/WordPress/gutenberg/pull/74246) - Bump Node.js requirement to 20.19 (https://github.com/WordPress/gutenberg/pull/74342) - `@wordpress/theme`: update `colorjs.io` to version `0.6.0` (https://github.com/WordPress/gutenberg/pull/74278) - HtmlRenderer: Merge style props (https://github.com/WordPress/gutenberg/pull/74344) - @wordpress/theme: disable color ramp unit tests (https://github.com/WordPress/gutenberg/pull/74347) - Update the useCommandLoader example to fix the syntax error and add missing imports. (https://github.com/WordPress/gutenberg/pull/73660) - Code Modernization: Use null coalescing operator in place of `isset()` in ternaries. (https://github.com/WordPress/gutenberg/pull/74335) - Preview drop down: align preview editing widths with common breakpoints (https://github.com/WordPress/gutenberg/pull/74339) - Media mime type field: Disable sorting for now (https://github.com/WordPress/gutenberg/pull/74373) - Remove commented-out note regarding redundant settings OPTIONS requests in preload tests. (https://github.com/WordPress/gutenberg/pull/74375) - Core Merge: Deduplicate Font Library page and routes (https://github.com/WordPress/gutenberg/pull/74381) - Build: Build minified and non minified CSS in both npm run dev and npm run build (https://github.com/WordPress/gutenberg/pull/74380) - Fix TypeScript error output in check-build-type-declaration-files script (https://github.com/WordPress/gutenberg/pull/74346) - Revert bump of Node.js to 20.19 (https://github.com/WordPress/gutenberg/pull/74385) - Packages: Add support for publishing stable release of pre-release package (https://github.com/WordPress/gutenberg/pull/74332) - Forms Block: Switch from dashicons to SVG (https://github.com/WordPress/gutenberg/pull/74297) - Fit-text: Refactor control hook for readability (https://github.com/WordPress/gutenberg/pull/74350) - Pattern Overrides: Infer partial syncing supported blocks from the server (https://github.com/WordPress/gutenberg/pull/73889) - Categories Block: Fix CSS collision with labels (https://github.com/WordPress/gutenberg/pull/73862) - Fix parent popover not closing on click outside (https://github.com/WordPress/gutenberg/pull/74340) - List View Panel: Fix circular dependency issue that was breaking some Storybook stories (https://github.com/WordPress/gutenberg/pull/74399) - Block: memoize canOverrideBlocks (https://github.com/WordPress/gutenberg/pull/74400) - Fix storybook:dev race condition with dev script (https://github.com/WordPress/gutenberg/pull/74290) - Image Cropper package: Add react peer dependencies (https://github.com/WordPress/gutenberg/pull/74402) - Build: use .mjs extensions for build-module files (https://github.com/WordPress/gutenberg/pull/74348) - MediaEdit: expanded view (https://github.com/WordPress/gutenberg/pull/74336) - Inspector Fields: Show DataForm driven Content tab for all blocks that support content fields (+ support block bindings) (https://github.com/WordPress/gutenberg/pull/73863) - Build: Faster repo building in CI (https://github.com/WordPress/gutenberg/pull/74406) - `@wordpress/keycodes`: add `ariaKeyShortcut` and `shortcutFormats ` exports (https://github.com/WordPress/gutenberg/pull/74205) - Create default Core Navigation Overlay patterns (https://github.com/WordPress/gutenberg/pull/74047) - Enhance Block Bindings Documentation as per WP 6.9 updates: Customizing supported attributes an `getFieldsList` (https://github.com/WordPress/gutenberg/pull/73763) - Patterns: Improve memoization in the overrides panel (https://github.com/WordPress/gutenberg/pull/74407) - Docs: Remove "Customizing supported attributes filter" section from Block Bindings docs (https://github.com/WordPress/gutenberg/pull/74410) - fix script module IDs to use configured packageNamespace (https://github.com/WordPress/gutenberg/pull/74411) - Update package version after an unfinished publish (https://github.com/WordPress/gutenberg/pull/74413) - UI: Add `Fieldset` primitives (https://github.com/WordPress/gutenberg/pull/74296) - DataViews: add density picker to list layout (https://github.com/WordPress/gutenberg/pull/71050) - UI: Add `Icon` component (https://github.com/WordPress/gutenberg/pull/74311) - Separator, Code: don't require Enter for shortcut (https://github.com/WordPress/gutenberg/pull/63654) - Theme: Update semibold font weight to apply workaround at CSS (https://github.com/WordPress/gutenberg/pull/74392) - Block visibility based on screen size: add rules to hide on viewport size (https://github.com/WordPress/gutenberg/pull/74379) - Media Fields: Add "Date added" and "Date modified" fields (https://github.com/WordPress/gutenberg/pull/74401) - Fix missing dependencies for packages (https://github.com/WordPress/gutenberg/pull/74310) - DataForm validation story: add support for the details layout (https://github.com/WordPress/gutenberg/pull/74445) - Quote: Fix transformation error (https://github.com/WordPress/gutenberg/pull/74253) - Stop building wp-build by renaming the src directory (https://github.com/WordPress/gutenberg/pull/74450) - Update: Use 12px as minimum font size for warning on fit text. (https://github.com/WordPress/gutenberg/pull/74387) - Render custom overlay template parts in Navigation block (behind experiment) (https://github.com/WordPress/gutenberg/pull/73967) - UI: add `Button` (https://github.com/WordPress/gutenberg/pull/74415) - iAPI: Preserve boolean HTML attributes during client side navigation (https://github.com/WordPress/gutenberg/pull/74446) - Blocks: cache url root when registering assets (https://github.com/WordPress/gutenberg/pull/74451) - Rename overlay area (https://github.com/WordPress/gutenberg/pull/74444) - Bump minimum required PHP version to 7.4. (https://github.com/WordPress/gutenberg/pull/74457) - Show Navigation overlay patterns on right sidebar (https://github.com/WordPress/gutenberg/pull/74069) - Blocks: Fix root url cache fatal error (https://github.com/WordPress/gutenberg/pull/74459) - CI: Run the PHP unit tests with the oldest and latest versions (https://github.com/WordPress/gutenberg/pull/74460) - added group label and 100vh (https://github.com/WordPress/gutenberg/pull/74458) - Convert dom-ready package to TypeScript (https://github.com/WordPress/gutenberg/pull/67671) - List View: Fix focus shift to the selected nested block (https://github.com/WordPress/gutenberg/pull/74431) - Media Fields: Add an attached_to field (https://github.com/WordPress/gutenberg/pull/74432) - Updated useBlockProps to utilize block visibility and device type from context, the intention is to reduce unnecessary store subscriptions. (https://github.com/WordPress/gutenberg/pull/74481) - Block Fields: Decouple the experiment from contentOnly/pattern editing experiments (https://github.com/WordPress/gutenberg/pull/74479) - Image: add focal point controls (https://github.com/WordPress/gutenberg/pull/73115) - MediaEdit: Add drag and drop functionality (https://github.com/WordPress/gutenberg/pull/74455) - DependencyExtractionWebpackPlugin: add ui as bundled package (https://github.com/WordPress/gutenberg/pull/74485) - Parent selector: Fix dot divider horizontal spacing (https://github.com/WordPress/gutenberg/pull/74329) - wp-build: Fix dynamic base-styles import (https://github.com/WordPress/gutenberg/pull/74434) - Plugin: Bump minimum required WordPress version to 6.8 (https://github.com/WordPress/gutenberg/pull/74218) - Pass `post_id` as an argument to `block_core_breadcrumbs_post_type_settings` filter to allow more granular term choice (https://github.com/WordPress/gutenberg/pull/74170) - Block Editor: Close the inserter on small screens after adding a block (https://github.com/WordPress/gutenberg/pull/74487) - `@wordpress/ui` `Button`: add `destructive` tone (https://github.com/WordPress/gutenberg/pull/74463) - Fix punctuation and formatting in README.md (https://github.com/WordPress/gutenberg/pull/74440) - Hide Display section from Nav Inspector Controls if empty (https://github.com/WordPress/gutenberg/pull/74495) - PHPCS: Include the `test` directory (https://github.com/WordPress/gutenberg/pull/48754) - dom-ready: Replace @ts-expect-error with MockDocument in tests (https://github.com/WordPress/gutenberg/pull/74482) - TypeScript: Migrate `packages/jest-puppeteer-axe` package to TypeScript (https://github.com/WordPress/gutenberg/pull/70523) - dom-ready: Refactor tests to use defineProperty (https://github.com/WordPress/gutenberg/pull/74514) - Dev: Fix file change logs not displaying in watch mode (https://github.com/WordPress/gutenberg/pull/74452) - Block Fields: show all form fields by default (https://github.com/WordPress/gutenberg/pull/74486) - Heading: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74383) - Fix the dataviews experiment locked fields position on toggle. (https://github.com/WordPress/gutenberg/pull/74326) - Fully resolve some intra-package import paths (https://github.com/WordPress/gutenberg/pull/74530) - TypeScript: Migrate shortcode package to TS. (https://github.com/WordPress/gutenberg/pull/74522) - Navigation Overlay: Fix area and icon name (https://github.com/WordPress/gutenberg/pull/74520) - Storybook: Update "Introduction" doc (https://github.com/WordPress/gutenberg/pull/74500) - Storybook: Retire old theme switcher (https://github.com/WordPress/gutenberg/pull/74499) - Add design-tokens.css to stylelintignore (https://github.com/WordPress/gutenberg/pull/74498) - fix nextpage-more-disable-visibility (https://github.com/WordPress/gutenberg/pull/74531) - `@wordpress/ui` `Button`: undo `destructive` tone variant (https://github.com/WordPress/gutenberg/pull/74540) - Update nested-blocks-inner-blocks.md (https://github.com/WordPress/gutenberg/pull/74534) - Clamp signaling server retries to prevent unbounded backoff (https://github.com/WordPress/gutenberg/pull/74372) - `@wordpress/ui` `Button`: refactor to base ui (https://github.com/WordPress/gutenberg/pull/74416) - Storybook: Remove "background" tools from toolbar (https://github.com/WordPress/gutenberg/pull/74538) - Storybook: Remove margin checker tool (https://github.com/WordPress/gutenberg/pull/74539) - Fix documentation title for @wordpress/build package (https://github.com/WordPress/gutenberg/pull/74541) - TypeScript: Convert notices package to TypeScript (https://github.com/WordPress/gutenberg/pull/67670) - Client side media: enhance queue system (https://github.com/WordPress/gutenberg/pull/74501) - Improve cross origin isolation support (https://github.com/WordPress/gutenberg/pull/74418) - Remove WebRTC and IndexedDB providers (https://github.com/WordPress/gutenberg/pull/74555) - Block Editor: Prevent browser autocomplete in Navigation link search (https://github.com/WordPress/gutenberg/pull/74305) - Query Title: Fix incorrect quotation marks with trailing spaces (https://github.com/WordPress/gutenberg/pull/74300) - Layout: Add allowWrap option to flex layout block support (https://github.com/WordPress/gutenberg/pull/74493) - Block visibility support: use CSS range syntax for media queries (https://github.com/WordPress/gutenberg/pull/74526) - Block visibility: add viewport modal and controls UI (https://github.com/WordPress/gutenberg/pull/74249) - Media Fields: Add readonly author field to media fields package and use in the media modal (https://github.com/WordPress/gutenberg/pull/74484) - Paragraph block: Stop using named export from block.json (https://github.com/WordPress/gutenberg/pull/74527) - Block Visibility: Fix block position shift when toggling (https://github.com/WordPress/gutenberg/pull/74535) - Block Fields: Remove normalization code and tidy up (https://github.com/WordPress/gutenberg/pull/74532) - Inserter: Prevent block-scope variations insertion in slash inserter (https://github.com/WordPress/gutenberg/pull/74259) - Fix formatting in block bindings documentation: Corrected links to core sources by adding hyphens (https://github.com/WordPress/gutenberg/pull/74414) - Theme/UI: Add intro docs to Storybook (https://github.com/WordPress/gutenberg/pull/74551) - Notes: Enable floating notes in template lock mode (https://github.com/WordPress/gutenberg/pull/74577) - Editor: Remove hardcoded autosave conditions for templates (https://github.com/WordPress/gutenberg/pull/73781) - Theme: enable color ramp tests and update snapshots (https://github.com/WordPress/gutenberg/pull/74403) - `@wordpress/ui` `Button`: tweak disabled styles and rework tokens (https://github.com/WordPress/gutenberg/pull/74470) - Fully resolve moment-timezone import, improve build optimization (https://github.com/WordPress/gutenberg/pull/74578) - Update navigation-overlay-close block to be used as server side rendering (https://github.com/WordPress/gutenberg/pull/74579) - Real-time collaboration: Allow post-locked-modal to be overridden when `collaborative-editing` is enabled (https://github.com/WordPress/gutenberg/pull/72326) - Menu: Remove animation on submenus (https://github.com/WordPress/gutenberg/pull/74548) - UI: Remove individual experimental tags from Storybook (https://github.com/WordPress/gutenberg/pull/74582) - UI: Add dark background for Storybook theme switcher (https://github.com/WordPress/gutenberg/pull/74318) - updates variant handling to pull files before access to temporary directory is removed (https://github.com/WordPress/gutenberg/pull/73986) - UI: Add `InputLayout` primitive (https://github.com/WordPress/gutenberg/pull/74313) - Customize: Preserve CSS cascade for Additional CSS in classic themes (https://github.com/WordPress/gutenberg/pull/74593) - Update TypeScript base config to use bundler module resolution (https://github.com/WordPress/gutenberg/pull/74560) - Block Editor: Add autoComplete attribute to prevent browser autocomplete (https://github.com/WordPress/gutenberg/pull/74595) - Publishing next packages: remove commit hash from version (https://github.com/WordPress/gutenberg/pull/74589) - Inserter: only show blocks that can be inserted on the page (https://github.com/WordPress/gutenberg/pull/74453) - Comments Title Block: Fix double quotes in non-English locales (https://github.com/WordPress/gutenberg/pull/74330) - DataViews stories: add custom layout (https://github.com/WordPress/gutenberg/pull/74605) - Navigation Overlay: Add default paragraph block (https://github.com/WordPress/gutenberg/pull/74592) - Components: Fix InputControl label overflow for long translations (https://github.com/WordPress/gutenberg/pull/74301) - Eslint: Add design token linting (https://github.com/WordPress/gutenberg/pull/74325) - Update Storybook to v10 with Vite builder (https://github.com/WordPress/gutenberg/pull/74396) - Navigations within overlays should not increment aria label attributs (https://github.com/WordPress/gutenberg/pull/74469) - Add template part context to navigation block (https://github.com/WordPress/gutenberg/pull/74614) - Navigation: When a navigation block has a custom overlay, the submenu colors should not apply to the overlay (https://github.com/WordPress/gutenberg/pull/74544) - Improve type safety with YMapWrap (https://github.com/WordPress/gutenberg/pull/73948) - Rename `--fast` build flag and use in Storybook build (https://github.com/WordPress/gutenberg/pull/74552) - Fix deprecations for Storybook component usage (https://github.com/WordPress/gutenberg/pull/74619) - Real-time collaboration: Use alternative diff in quill-delta, provide incremental text updates (https://github.com/WordPress/gutenberg/pull/73699) - Real-time collaboration: Move collaborative editing from experiments to default Gutenberg plugin experience (https://github.com/WordPress/gutenberg/pull/74562) - Real-time Collaboration: Add Yjs awareness foundation (https://github.com/WordPress/gutenberg/pull/74565) - Image Block: Fix empty block content tools when multiselecting image blocks (https://github.com/WordPress/gutenberg/pull/74604) - Content-only: remove `mapping` and `args` in favor of DataForm API (https://github.com/WordPress/gutenberg/pull/74575) - TypeScript: Convert redux-store types in data package to TS (https://github.com/WordPress/gutenberg/pull/67666) - Add list view inspector tab for pattern editing (https://github.com/WordPress/gutenberg/pull/74574) - api-fetch: Add named export to fix TypeScript callable issues (https://github.com/WordPress/gutenberg/pull/74576) - Fix: Dataview: column header move item in RTL moves in the opposite direction to the arrow (https://github.com/WordPress/gutenberg/pull/74644) - UI: Add `Input` primitive (https://github.com/WordPress/gutenberg/pull/74615) - Improve wp-build generated PHP files with proper prefixing and naming (https://github.com/WordPress/gutenberg/pull/74490) - Navigation Submenu: Show (Invalid) indicator when parent page is deleted (https://github.com/WordPress/gutenberg/pull/74461) - components: Fix generated TS types referencing unavailable `csstype` (https://github.com/WordPress/gutenberg/pull/74655) - Real-time collaboration: Refetch entity when it is saved by a peer (https://github.com/WordPress/gutenberg/pull/74637) - add a white background to the overlay default pattern (https://github.com/WordPress/gutenberg/pull/74659) - Infrastructure: Convert storybook to a workspace package (https://github.com/WordPress/gutenberg/pull/74640) - Remove unused dependencies (https://github.com/WordPress/gutenberg/pull/74624) - Apply only detected changes from the persisted CRDT document (https://github.com/WordPress/gutenberg/pull/74668) - Enable components manifest for Storybook (https://github.com/WordPress/gutenberg/pull/74626) - Move ESLint rules specific to `@wordpress/components` to custom rules (https://github.com/WordPress/gutenberg/pull/74611) - Navigaiton: Refactor SCSS to reduce duplication (https://github.com/WordPress/gutenberg/pull/74666) - Site Editor: If the route cannot be found treat the canvas mode as view (https://github.com/WordPress/gutenberg/pull/74642) - `@wordpress/components`: lint and fix `@wordpress/components-no-missing-40px-size-prop` rule (https://github.com/WordPress/gutenberg/pull/74622) - Block visibility supports: refactor metadata to use nested structure (https://github.com/WordPress/gutenberg/pull/74602) - Media Editor: Add a simple media editor package and integrate into the editor package (https://github.com/WordPress/gutenberg/pull/74601) - Embed: Fix Flickr double-padding with responsive wrapper (https://github.com/WordPress/gutenberg/pull/73902) - Block visibility: render blocks when hidden at all viewports (and other changes) (https://github.com/WordPress/gutenberg/pull/74679) - Add missing chevron-up-small icon. (https://github.com/WordPress/gutenberg/pull/74607) - List View: Ensure element exists in document before focusing (https://github.com/WordPress/gutenberg/pull/74613) - Allow for themes to define the overlay attribute without using a theme slug (https://github.com/WordPress/gutenberg/pull/74119) - DataViews: Fix insert left and right handling in table layout for RTL languages (https://github.com/WordPress/gutenberg/pull/74681) - SlotFill: unify registry and fill implementation (https://github.com/WordPress/gutenberg/pull/68056) - Storybook: Automate sidebar sort order (https://github.com/WordPress/gutenberg/pull/74672) - Fix: Update function names to include wp_ prefix (https://github.com/WordPress/gutenberg/pull/74688) - Make custom navigation overlay full width (https://github.com/WordPress/gutenberg/pull/74559) - Components: Add `@types/react` to dependencies for TypeScript type resolution (https://github.com/WordPress/gutenberg/pull/74692) - Core backport for Global Styles: Allow arbitrary CSS, protect from KSES mangling (https://github.com/WordPress/gutenberg/pull/74371) - UI: Add `Select` primitive (https://github.com/WordPress/gutenberg/pull/74661) - Badge: Use stories for "Choosing intent" doc (https://github.com/WordPress/gutenberg/pull/74675) - Add `Tooltip` component to `@wordpress/ui` (https://github.com/WordPress/gutenberg/pull/74625) - Image block: show aspect ratio control for wide and full alignment (https://github.com/WordPress/gutenberg/pull/74519) - Bump the github-actions group across 1 directory with 3 updates (https://github.com/WordPress/gutenberg/pull/74002) - Bump mdast-util-to-hast from 13.1.0 to 13.2.1 in /platform-docs (https://github.com/WordPress/gutenberg/pull/73683) - Updated Minor Typo in Compatibility Rest API File (https://github.com/WordPress/gutenberg/pull/74718) - Block Editor Provider: Fix conditional useMemo call when media processing experiment is active (https://github.com/WordPress/gutenberg/pull/74680) - Reset inspector tab selection if the selected tab is no longer present (https://github.com/WordPress/gutenberg/pull/74682) - Remove react-refresh bundling (https://github.com/WordPress/gutenberg/pull/74721) - iAPI: Fix and refactor runtime initialization logic (https://github.com/WordPress/gutenberg/pull/71123) - Comment Edit Link: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74720) - Update wp-build documentation to describe 'wpPlugin.name' (https://github.com/WordPress/gutenberg/pull/74741) - Navigation Overlay: insert default pattern on creation (https://github.com/WordPress/gutenberg/pull/74650) - DataViews: Use regular casing for bulk selection count (https://github.com/WordPress/gutenberg/pull/74573) - Fix wp-theme dependencies in the build. (https://github.com/WordPress/gutenberg/pull/74743) - Do not wrap persisted doc applied update in transaction (https://github.com/WordPress/gutenberg/pull/74753) - Revert "Fixed Media & Text Block - Image not rendered properly on frontend when inside stack (https://github.com/WordPress/gutenberg/pull/68610)" (https://github.com/WordPress/gutenberg/pull/74715) - Create Block: Simplify blocks-manifest registration (https://github.com/WordPress/gutenberg/pull/74647) - Pattern Editing: Prevent double-click editing template parts and synced patterns (https://github.com/WordPress/gutenberg/pull/74755) - Paragraph: Add text column support (https://github.com/WordPress/gutenberg/pull/74656) - Update overlay control labels (https://github.com/WordPress/gutenberg/pull/74690) - Comment Date: Add textAlign Support (https://github.com/WordPress/gutenberg/pull/74599) - Don't show overlay settings for navigation blocks that are inside oth… (https://github.com/WordPress/gutenberg/pull/74408) - Remove the apiFetch named export (https://github.com/WordPress/gutenberg/pull/74761) - MediaEdit: Support `custom` validation (https://github.com/WordPress/gutenberg/pull/74704) - components: Add `displayName` to the anonymous components (https://github.com/WordPress/gutenberg/pull/74716) - Pattern Overrides: Remove obsolete documentation (https://github.com/WordPress/gutenberg/pull/74749) - Verse Block: Add new textAlign support (https://github.com/WordPress/gutenberg/pull/74724) - DataViews: Move filtering logic in field types (https://github.com/WordPress/gutenberg/pull/74733) - Fix: can't disable textColumns UI (https://github.com/WordPress/gutenberg/pull/74767) - Navigation: Don't use a nav tag for navigation blocks inside overlays (https://github.com/WordPress/gutenberg/pull/74764) - Allow grid layout to use theme blockGap values for columns calculation (https://github.com/WordPress/gutenberg/pull/74725) - Move grid manual mode sync into 7.1 folder (https://github.com/WordPress/gutenberg/pull/74792) - Show block content for label in List View (https://github.com/WordPress/gutenberg/pull/74794) - Ensure grid column never exceeds parent's width (https://github.com/WordPress/gutenberg/pull/74795) - Term List block: Pre-select current term on term archive pages (https://github.com/WordPress/gutenberg/pull/74603) - Update performance results endpoint to codevitals.run (https://github.com/WordPress/gutenberg/pull/74802) - Update performance results endpoint to use fetch API for redirect handling (https://github.com/WordPress/gutenberg/pull/74803) - iAPI: Update deprecation warning for unique ID format (https://github.com/WordPress/gutenberg/pull/74580) - Cover Block: Enable focal point picker for fixed background (https://github.com/WordPress/gutenberg/pull/74600) - Blocks: Always trigger borwser console warnings for blocks with apiVersion below 2 (https://github.com/WordPress/gutenberg/pull/74057) - Fix typo in comment for value change check (https://github.com/WordPress/gutenberg/pull/74730) - Move useIsDraggingWithin to a shared hook (https://github.com/WordPress/gutenberg/pull/74804) - Include totals items count in DataView footer (https://github.com/WordPress/gutenberg/pull/73491) - Storybook: Fix missing props from component stories (https://github.com/WordPress/gutenberg/pull/74807) - Breadcrumbs :Add example block previews (https://github.com/WordPress/gutenberg/pull/74808) - Core backport for gutenberg_filter_global_styles_post: Protect from KSES mangling (https://github.com/WordPress/gutenberg/pull/74731) - Move selectLabelText to shared utility (https://github.com/WordPress/gutenberg/pull/74805) - Bump node-forge from 1.3.1 to 1.3.3 in /platform-docs (https://github.com/WordPress/gutenberg/pull/74292) - Fix blockGap styles not working in block style variations (https://github.com/WordPress/gutenberg/pull/74529) - Comment Reply Link: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74760) - Try storing global styles in static var in layout render (https://github.com/WordPress/gutenberg/pull/74828) - List View support: show full block titles (https://github.com/WordPress/gutenberg/pull/74798) - Pattern Editing: Update template part to use tabs (https://github.com/WordPress/gutenberg/pull/74793) - Block visibility: create selectors for block visibility in current viewport (device setting or responsive) (https://github.com/WordPress/gutenberg/pull/74517) - Fix: add border-box sizing for verse block (https://github.com/WordPress/gutenberg/pull/74722) - Block Visibility: fix failing unit test (https://github.com/WordPress/gutenberg/pull/74840) - Breadcrumbs: Fix placeholder separator preview (https://github.com/WordPress/gutenberg/pull/74842) - Dataviews: Fix actions visibility on smaller viewpoints and for lone action with isPrimary as true (https://github.com/WordPress/gutenberg/pull/74836) - Navigation Overlay: Add sidebar preview (https://github.com/WordPress/gutenberg/pull/74780) - Show submenu colors but remove the word overlay (https://github.com/WordPress/gutenberg/pull/74818) - E2e tests: remove editor.switchToLegacyCanvas from multi select and a11y suite (https://github.com/WordPress/gutenberg/pull/74845) - Enable build-blocks-manifest by default (https://github.com/WordPress/gutenberg/pull/74846) - Direct drag: fix glitching around scrolling (https://github.com/WordPress/gutenberg/pull/74608) - Handle deleted navigation overlays (https://github.com/WordPress/gutenberg/pull/74766) - iAPI Router: Prevent router regions with `data-wp-key` from being recreated on navigation (https://github.com/WordPress/gutenberg/pull/74750) - iAPI Router: Fix initial router regions with `attachTo` being duplicated after `navigate()` (https://github.com/WordPress/gutenberg/pull/74857) - DataViews: Adjust table primary media field styles (https://github.com/WordPress/gutenberg/pull/74813) - Fix: Escape less-than character in HTML attributes to prevent block recovery errors (https://github.com/WordPress/gutenberg/pull/74732) - DataViews: Update storybook to add more context (https://github.com/WordPress/gutenberg/pull/74819) - Sync: Refactor ProviderCreator signature to an object (https://github.com/WordPress/gutenberg/pull/74871) - Real-time Collaboration: Add user and selection information to awareness (https://github.com/WordPress/gutenberg/pull/74728) - Add custom CSS support for individual block instances (https://github.com/WordPress/gutenberg/pull/73959) - Style Engine: Bail early when adding a declaration if not passed a string (https://github.com/WordPress/gutenberg/pull/74881) - Stabilise viewport based block visibility (https://github.com/WordPress/gutenberg/pull/74839) - Navigation: Add a new option that toggles submenus always open (https://github.com/WordPress/gutenberg/pull/74653) - Fix: Fit Text not working on calculated line heights. (https://github.com/WordPress/gutenberg/pull/74860) - Fix: Safari "Edit as HTML" for Fit Text deletes content (https://github.com/WordPress/gutenberg/pull/74864) - Route: Add notFound to public API and add route validation (https://github.com/WordPress/gutenberg/pull/74867) - DataForm: add `combobox` control (https://github.com/WordPress/gutenberg/pull/74891) - Real-time collaboration: Use relative positions in undo stack (https://github.com/WordPress/gutenberg/pull/74878) - MediaReplaceFlow: Move Reset option to bottom of menu (https://github.com/WordPress/gutenberg/pull/74882) - Real-time collaboration: Sync collections (https://github.com/WordPress/gutenberg/pull/74665) - Feat/core tabs restructure (https://github.com/WordPress/gutenberg/pull/74412) - Inserter: Fix missing onClose prop for Inserter Menu (https://github.com/WordPress/gutenberg/pull/74920) - Post Excerpt Block: Fixing max limits for generated excerpts (https://github.com/WordPress/gutenberg/pull/74140) - Post Excerpt Block: Fix excerpt trimming logic to handle whitespace correctly (https://github.com/WordPress/gutenberg/pull/74925) - e2e: fix flaky tests for settings sidebar (https://github.com/WordPress/gutenberg/pull/74929) - Comments Title: Copy deprecate from block.json to deprecated.js to avoid legacy attribute usage (https://github.com/WordPress/gutenberg/pull/74924) - Added Missing Global Documentation (https://github.com/WordPress/gutenberg/pull/74868) - dataviews: Fix missing dependency - @storybook/addon-docs (https://github.com/WordPress/gutenberg/pull/74935) - Patterns: restore rename and delete actions for user patterns (https://github.com/WordPress/gutenberg/pull/74927) - DataViews: Add card form layout validation (https://github.com/WordPress/gutenberg/pull/74547) - E2e tests: remove switchToLegacyCanvas from inserter drag and drop tests (https://github.com/WordPress/gutenberg/pull/74892) - Navigation Overlays: Default new blocks to "always" show overlays (https://github.com/WordPress/gutenberg/pull/74890) - Remove link underline style from default theme.json (https://github.com/WordPress/gutenberg/pull/74901) - selectBlock: fall back to next block if no previous block is present (https://github.com/WordPress/gutenberg/pull/74938) - Update: Preserve additional meta properties on client side abilities. (https://github.com/WordPress/gutenberg/pull/73918) - E2e tests: bump all test blocks to API v3 (https://github.com/WordPress/gutenberg/pull/74941) - Cover Block: Show current embed URL in dialog (https://github.com/WordPress/gutenberg/pull/74885) - core-data: Fix missing dependencies (https://github.com/WordPress/gutenberg/pull/74934) - Build script: Increase memory limit for storybook build process (https://github.com/WordPress/gutenberg/pull/74933) - Real-time collaboration: Pass non-cleaned (but merged) edits to `SyncManager#update` (https://github.com/WordPress/gutenberg/pull/74912) - Navigation overlay patterns: overlay with black background (https://github.com/WordPress/gutenberg/pull/74847) - Navigation overlay patterns: overlay with accent background (https://github.com/WordPress/gutenberg/pull/74849) - Shortcode: Fix non-string attribute values being silently dropped (https://github.com/WordPress/gutenberg/pull/74949) - core-data: Fix yjs import and missing dependency (https://github.com/WordPress/gutenberg/pull/74950) - Icons: Add a manifest containing icons metadata (https://github.com/WordPress/gutenberg/pull/74943) - Babel Preset Default: Remove legacy plugins (https://github.com/WordPress/gutenberg/pull/74916) - Real-time collaboration: Fix undo tests (https://github.com/WordPress/gutenberg/pull/74955) - BlockBreadcrumb: Show custom block name (https://github.com/WordPress/gutenberg/pull/73690) - Fix: Stretchy text issue when nested on flex containers. (https://github.com/WordPress/gutenberg/pull/73652) - iAPI: Don't use deprecated `data-wp-on-async` in docs (https://github.com/WordPress/gutenberg/pull/72591) - Comments Title: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74945) - iAPI Docs: add config to state/context guide (https://github.com/WordPress/gutenberg/pull/71355) - Add content element guidelines for fields in DataForm (https://github.com/WordPress/gutenberg/pull/74817) - Navigation overlay patterns: centered navigation with info (https://github.com/WordPress/gutenberg/pull/74862) - In-editor revisions (initial changes, no diffing) (https://github.com/WordPress/gutenberg/pull/74771) - Docs: Add missing @global documentation in REST assets controller (https://github.com/WordPress/gutenberg/pull/74973) - Docs: Add missing @return tags to experimental functions (https://github.com/WordPress/gutenberg/pull/74960) - Docs: Replace @see with @link for URL references (https://github.com/WordPress/gutenberg/pull/74961) - Gallery block: Image Caption Blur Issue Fix (https://github.com/WordPress/gutenberg/pull/74063) - Inserter Component: Improving Stories (https://github.com/WordPress/gutenberg/pull/74922) - Block Visibility: fix flaky e2e test (https://github.com/WordPress/gutenberg/pull/74931) - Media Modal Experiment: Add a simple notices-based uploading state (https://github.com/WordPress/gutenberg/pull/74965) - Docs: Standardize use of @link tag for URL references in lib directory (https://github.com/WordPress/gutenberg/pull/74984) - Pattern editing: stabilize and remove the experiment flag (https://github.com/WordPress/gutenberg/pull/74843) - Remove comment about non-existing property (https://github.com/WordPress/gutenberg/pull/75003) - Video block: Fix video URLs pasted without "https://" show broken media (https://github.com/WordPress/gutenberg/pull/74964) - Fix flaky 'Revisions' e2e test (https://github.com/WordPress/gutenberg/pull/75002) - Build: deduplicate and minify embedded styles (https://github.com/WordPress/gutenberg/pull/74651) - Navigation overlay patterns: centered navigation (https://github.com/WordPress/gutenberg/pull/74861) - wp-env: Add experimental WordPress Playground runtime support (https://github.com/WordPress/gutenberg/pull/74609) - Consolidate border tokens (https://github.com/WordPress/gutenberg/pull/74617) - Add the `has-custom-css` class name to the editor and dynamic blocks. (https://github.com/WordPress/gutenberg/pull/74969) - Real-time collaboration: Add default HTTP polling sync provider (https://github.com/WordPress/gutenberg/pull/74564) - eslint-plugin: Add "never" option for dependency-group rule (https://github.com/WordPress/gutenberg/pull/74990) - Design System: Add guidelines for destructive actions UX (https://github.com/WordPress/gutenberg/pull/74778) - DataViews: Show validation errors when a panel closes (https://github.com/WordPress/gutenberg/pull/74995) - DataForm: Sync React-level validation to native inputs on date fields. (https://github.com/WordPress/gutenberg/pull/74994) - Pattern Editing: Hide List View child blocks in Content panel (https://github.com/WordPress/gutenberg/pull/75007) - Infrastructure: Add storybook to tsconfig project references (https://github.com/WordPress/gutenberg/pull/74887) - Real-time Collaboration: Add hook for accessing awareness data (https://github.com/WordPress/gutenberg/pull/75009) - Hide grid visualiser if the grid block is hidden (https://github.com/WordPress/gutenberg/pull/74963) - Add unit test for gap in block style variations fix (https://github.com/WordPress/gutenberg/pull/75038) - Post Excerpt: Disable HTML element insertion (https://github.com/WordPress/gutenberg/pull/74928) - Deprecate 'Post author' block (https://github.com/WordPress/gutenberg/pull/55352) - Fix emdashes in HTML anchor description (https://github.com/WordPress/gutenberg/pull/75043) - In-editor revisions: preserve client IDs to prevent flashes/remounts (https://github.com/WordPress/gutenberg/pull/75028) - Playlist block (https://github.com/WordPress/gutenberg/pull/50664) - Media & Text: Respect image_default_link_type option (https://github.com/WordPress/gutenberg/pull/74295) - Author Biography: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/74997) - Dataform: Adds validation support to the DataForm details layout (https://github.com/WordPress/gutenberg/pull/74996) - Docs: Clarifies cherry-picking permissions and improves minor release workflow documentation (https://github.com/WordPress/gutenberg/pull/75034) - Routing Boot Package: Remove left border from stage and inspector surfaces (https://github.com/WordPress/gutenberg/pull/75036) - Replace install-path command with status command in wp-env (https://github.com/WordPress/gutenberg/pull/75020) - Remove temp files (https://github.com/WordPress/gutenberg/pull/75061) - Update and unpin sync package dependencies (https://github.com/WordPress/gutenberg/pull/75059) - Navigation Overlay: Add Create Overlay button (https://github.com/WordPress/gutenberg/pull/74971) - Try hiding parent grid cells when child grid is selected. (https://github.com/WordPress/gutenberg/pull/75078) - Notes: Use preferences store when applicable (https://github.com/WordPress/gutenberg/pull/75008) - Notes: Don't trigger reflow for pinned sidebar (https://github.com/WordPress/gutenberg/pull/75010) - Resize meta box pane without `ResizableBox` (https://github.com/WordPress/gutenberg/pull/66735) - `@wordpress/ui`: add `IconButton` (https://github.com/WordPress/gutenberg/pull/74697) - Private APIs: remove duplicate `@wordpress/ui` entry (https://github.com/WordPress/gutenberg/pull/75051) - DataViews: Fix title truncation in `list` layout (https://github.com/WordPress/gutenberg/pull/75063) - Custom CSS support: Add attributes for dynamic blocks. (https://github.com/WordPress/gutenberg/pull/75052) - DataViews: Fix fields async validation (https://github.com/WordPress/gutenberg/pull/74948) - Unified view persistence: Share one persisted view across all tabs (https://github.com/WordPress/gutenberg/pull/74970) - SVG Icon registration API (https://github.com/WordPress/gutenberg/pull/72215) - Navigation: Use :where on the :not(.disable-default-overlay) selector so that the scope doesn't change. (https://github.com/WordPress/gutenberg/pull/75090) - wp-env: Fix MySQL startup race condition causing database connection errors (https://github.com/WordPress/gutenberg/pull/75046) - RichText: fix white space collapsing arround formatting (https://github.com/WordPress/gutenberg/pull/74820) - Docs: Add missing @global documentation in rtl.php and meta-box.php (https://github.com/WordPress/gutenberg/pull/75082) - Blocks: Try prepending 'https' to URLs without protocol (https://github.com/WordPress/gutenberg/pull/75005) - wp-env: Add cleanup command and force flag (https://github.com/WordPress/gutenberg/pull/75045) - DataViews: Add `title` attribute in `grid` item title field (https://github.com/WordPress/gutenberg/pull/75085) - wp-env: Fix mixed runtime detection issues (https://github.com/WordPress/gutenberg/pull/75057) - `@wordpress/ui`: add `Tabs` (https://github.com/WordPress/gutenberg/pull/74652) - Run generate-worker-placeholders script in dev (https://github.com/WordPress/gutenberg/pull/75104) - Docs: Add missing @global documentation in block library (https://github.com/WordPress/gutenberg/pull/75004) - Site Editor: Prevent welcome guide from appearing during loading (https://github.com/WordPress/gutenberg/pull/75102) - Media Fields: Fix filename field truncation (https://github.com/WordPress/gutenberg/pull/75091) - Block Supports: Add Line Indent support using enum setting (https://github.com/WordPress/gutenberg/pull/74889) - useBlockVisibility: consolidate useMemo calls to the output object (https://github.com/WordPress/gutenberg/pull/75120) - Post Author Name: Migrate to text-align block support (https://github.com/WordPress/gutenberg/pull/75109) - Restore deprecated Pullquote Block (https://github.com/WordPress/gutenberg/pull/75122) - useBlockVisibility: Remove the last 'useMemo' call (https://github.com/WordPress/gutenberg/pull/75125) - remove horizontal scroll (https://github.com/WordPress/gutenberg/pull/75086) - Refactor activeFilters to activeViewOverrides with date sort for User tab (https://github.com/WordPress/gutenberg/pull/75094) - Post Content Block: Improve removal confirmation modal (https://github.com/WordPress/gutenberg/pull/75001) - DataViews: Consistent rendering of selection checkbox and actions in `grid` layout (https://github.com/WordPress/gutenberg/pull/75056) - Pullquote: Fix deprecated block validation when anchor/id attribute is present (https://github.com/WordPress/gutenberg/pull/75132) - Add URL validation in LinkControl using ValidatedInputControl (https://github.com/WordPress/gutenberg/pull/73486) - Components: remove "text-wrap: balance" fallback from Text (https://github.com/WordPress/gutenberg/pull/75089) - Image Block: Handle image URLs without protocol (https://github.com/WordPress/gutenberg/pull/75135) - fix the color of the overlay to fix contrast issues on dark themes (https://github.com/WordPress/gutenberg/pull/74979) - Admin UI: apply 'text-wrap: pretty' to Page (https://github.com/WordPress/gutenberg/pull/74907) - Fix dev build for fresh checkouts (or with build/scripts/block-library missing) (https://github.com/WordPress/gutenberg/pull/75108) - Calculate viewport based on iframe size in resizable editor. (https://github.com/WordPress/gutenberg/pull/75156) - Media Modal Experiment: Remove default value for allowedTypes so that the file block can accept all types (https://github.com/WordPress/gutenberg/pull/75159) - wp-env Playground: Support zip archive themes (https://github.com/WordPress/gutenberg/pull/75155) - Block Editor: Allow stable block IDs in block editor store (https://github.com/WordPress/gutenberg/pull/74687) - Code Quality: Remove deprecated __nextHasNoMarginBottom prop (https://github.com/WordPress/gutenberg/pull/75139) - Migrate textAlign attributes from the Author block to block support when migrating. (https://github.com/WordPress/gutenberg/pull/75153) - Scripts: Fix contributor guide link in README (https://github.com/WordPress/gutenberg/pull/75161) - ToggleGroupControl: add visual emphasis to selected item (https://github.com/WordPress/gutenberg/pull/75138) - Image block: Add missing space between sentences (https://github.com/WordPress/gutenberg/pull/75142) - DOM: exclude inert elements from focus.focusable (https://github.com/WordPress/gutenberg/pull/75172) - Writing flow: fix Cmd+A from empty RichText (https://github.com/WordPress/gutenberg/pull/75175) - Theme: Update dimension tokens (https://github.com/WordPress/gutenberg/pull/75054) - Build: Add vendorScripts config to build packages from node_modules (https://github.com/WordPress/gutenberg/pull/74343) - ui/`Button`: add min width (https://github.com/WordPress/gutenberg/pull/75133) - Navigation: Consolidate SVG rendering functions to a shared helper (https://github.com/WordPress/gutenberg/pull/74853) - RangeControl: support forced-colors mode (https://github.com/WordPress/gutenberg/pull/75165) - Restrict base-ui imports outside of UI component packages (https://github.com/WordPress/gutenberg/pull/75143) - Remove the React Native test status badges. (https://github.com/WordPress/gutenberg/pull/74674) - DataViews: externalize theme stylesheet (https://github.com/WordPress/gutenberg/pull/75182) - Media Modal Experiment: Update preview size to be a little smaller (https://github.com/WordPress/gutenberg/pull/75191) - Env: Remove non-functional `WP_ENV_MULTISITE` config (https://github.com/WordPress/gutenberg/pull/72567) - Cover block: Force LTR direction for the background URL input field (https://github.com/WordPress/gutenberg/pull/75169) - Tabs block: Polish (https://github.com/WordPress/gutenberg/pull/75128) - Real-time Collaboration: Add collaborators presence UI (https://github.com/WordPress/gutenberg/pull/75065) - DataForm: mark fields as required or optional automatically (https://github.com/WordPress/gutenberg/pull/74430) - ToggleControl: pass full props to the input element (https://github.com/WordPress/gutenberg/pull/74956) - Media & Text: Fix RTLCSS control directives appearing in production CSS (https://github.com/WordPress/gutenberg/pull/73205) - @wordpress/ui: use semantic dimension tokens (https://github.com/WordPress/gutenberg/pull/74557) - Fix duplicate content when navigation overlay is open and nav has non-link inner blocks (https://github.com/WordPress/gutenberg/pull/75180) - Group fix example text-align attributes (https://github.com/WordPress/gutenberg/pull/75200) - Editor: Introduce new selectedNote editor state (https://github.com/WordPress/gutenberg/pull/75177) - Block Support: Allow serialization skipping for ariaLabel (https://github.com/WordPress/gutenberg/pull/75192) git-svn-id: https://develop.svn.wordpress.org/trunk@61605 602fd350-edb4-49c9-b593-d223f7449a82 --- package.json | 2 +- src/wp-admin/includes/update-core.php | 4 - .../class-wp-rest-posts-controller.php | 4 +- .../data/blocks/do-blocks-expected.html | 4 +- .../blocks/fixtures/core__column.server.html | 12 +-- .../blocks/fixtures/core__columns.server.html | 32 +++---- .../core__columns__deprecated.server.html | 24 ++--- .../fixtures/core__media-text.server.html | 2 +- ...media-text__image-alt-no-align.server.html | 2 +- ...dia-text__is-stacked-on-mobile.server.html | 2 +- ...text__media-right-custom-width.server.html | 2 +- .../core__media-text__video.server.html | 2 +- .../core__paragraph__align-right.server.html | 2 +- .../includes/unregister-blocks-hooks.php | 89 +++---------------- .../tests/block-bindings/postMetaSource.php | 18 ++-- tests/phpunit/tests/block-bindings/render.php | 10 +-- tests/phpunit/tests/blocks/render.php | 3 + tests/phpunit/tests/blocks/renderReusable.php | 4 +- .../tests/formatting/excerptRemoveBlocks.php | 8 +- tests/phpunit/tests/post/output.php | 8 +- .../rest-api/rest-widgets-controller.php | 2 +- tools/gutenberg/copy-gutenberg-build.js | 19 ++++ 22 files changed, 104 insertions(+), 151 deletions(-) diff --git a/package.json b/package.json index 9ff5ddd3dae97..66c8e0b5b23af 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "ref": "7bf80ea84eb8b62eceb1bb3fe82e42163673ca79" + "ref": "59a08c5496008ca88f4b6b86f38838c3612d88c8" }, "engines": { "node": ">=20.10.0", diff --git a/src/wp-admin/includes/update-core.php b/src/wp-admin/includes/update-core.php index 2f8045e8d193f..47cbc9e16fb64 100644 --- a/src/wp-admin/includes/update-core.php +++ b/src/wp-admin/includes/update-core.php @@ -840,10 +840,6 @@ 'wp-includes/js/dist/fields.min.js', 'wp-includes/js/dist/fields.js', // 6.9 - 'wp-includes/blocks/post-author/editor.css', - 'wp-includes/blocks/post-author/editor.min.css', - 'wp-includes/blocks/post-author/editor-rtl.css', - 'wp-includes/blocks/post-author/editor-rtl.min.css', 'wp-includes/SimplePie/src/Decode', 'wp-includes/SimplePie/src/Core.php', ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 14afb8c2eeddf..a69ce9fa95454 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -1988,7 +1988,7 @@ public function prepare_item_for_response( $item, $request ) { add_filter( 'excerpt_length', $override_excerpt_length, - 20 + PHP_INT_MAX ); } @@ -2008,7 +2008,7 @@ public function prepare_item_for_response( $item, $request ) { remove_filter( 'excerpt_length', $override_excerpt_length, - 20 + PHP_INT_MAX ); } } diff --git a/tests/phpunit/data/blocks/do-blocks-expected.html b/tests/phpunit/data/blocks/do-blocks-expected.html index f413182051334..f46ab0b7628c5 100644 --- a/tests/phpunit/data/blocks/do-blocks-expected.html +++ b/tests/phpunit/data/blocks/do-blocks-expected.html @@ -3,7 +3,7 @@ -

First Gutenberg Paragraph

+

First Gutenberg Paragraph

Second Auto Paragraph

@@ -11,7 +11,7 @@ -

Third Gutenberg Paragraph

+

Third Gutenberg Paragraph

Third Auto Paragraph

diff --git a/tests/phpunit/data/blocks/fixtures/core__column.server.html b/tests/phpunit/data/blocks/fixtures/core__column.server.html index d0b6ab4016d91..2487ea0f93c9b 100644 --- a/tests/phpunit/data/blocks/fixtures/core__column.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__column.server.html @@ -1,10 +1,10 @@
- -

Column One, Paragraph One

- - -

Column One, Paragraph Two

- + +

Column One, Paragraph One

+ + +

Column One, Paragraph Two

+
diff --git a/tests/phpunit/data/blocks/fixtures/core__columns.server.html b/tests/phpunit/data/blocks/fixtures/core__columns.server.html index 5b5faf4f3d7a4..fa39f71320056 100644 --- a/tests/phpunit/data/blocks/fixtures/core__columns.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__columns.server.html @@ -1,24 +1,24 @@
- +
- -

Column One, Paragraph One

- - -

Column One, Paragraph Two

- + +

Column One, Paragraph One

+ + +

Column One, Paragraph Two

+
- - + +
- -

Column Two, Paragraph One

- - -

Column Three, Paragraph One

- + +

Column Two, Paragraph One

+ + +

Column Three, Paragraph One

+
- +
diff --git a/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html b/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html index b19349744dfbc..af37434febcf8 100644 --- a/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html @@ -1,16 +1,16 @@
- -

Column One, Paragraph One

- - -

Column One, Paragraph Two

- - -

Column Two, Paragraph One

- - -

Column Three, Paragraph One

- + +

Column One, Paragraph One

+ + +

Column One, Paragraph Two

+ + +

Column Two, Paragraph One

+ + +

Column Three, Paragraph One

+
diff --git a/tests/phpunit/data/blocks/fixtures/core__media-text.server.html b/tests/phpunit/data/blocks/fixtures/core__media-text.server.html index e472aeb90e182..9093acb972b23 100644 --- a/tests/phpunit/data/blocks/fixtures/core__media-text.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__media-text.server.html @@ -5,7 +5,7 @@
-

My Content

+

My Content

diff --git a/tests/phpunit/data/blocks/fixtures/core__media-text__image-alt-no-align.server.html b/tests/phpunit/data/blocks/fixtures/core__media-text__image-alt-no-align.server.html index c25f5431b6809..15d5feec2b849 100644 --- a/tests/phpunit/data/blocks/fixtures/core__media-text__image-alt-no-align.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__media-text__image-alt-no-align.server.html @@ -5,7 +5,7 @@
-

Content

+

Content

diff --git a/tests/phpunit/data/blocks/fixtures/core__media-text__is-stacked-on-mobile.server.html b/tests/phpunit/data/blocks/fixtures/core__media-text__is-stacked-on-mobile.server.html index 5a1c3993f54c9..a8ddd142e6c00 100644 --- a/tests/phpunit/data/blocks/fixtures/core__media-text__is-stacked-on-mobile.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__media-text__is-stacked-on-mobile.server.html @@ -5,7 +5,7 @@
-

My Content

+

My Content

diff --git a/tests/phpunit/data/blocks/fixtures/core__media-text__media-right-custom-width.server.html b/tests/phpunit/data/blocks/fixtures/core__media-text__media-right-custom-width.server.html index 41edc3e02ebb8..2c2cbe3da310d 100644 --- a/tests/phpunit/data/blocks/fixtures/core__media-text__media-right-custom-width.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__media-text__media-right-custom-width.server.html @@ -5,7 +5,7 @@
-

My video

+

My video

diff --git a/tests/phpunit/data/blocks/fixtures/core__media-text__video.server.html b/tests/phpunit/data/blocks/fixtures/core__media-text__video.server.html index 88e9393dd75f2..4328c16b367a0 100644 --- a/tests/phpunit/data/blocks/fixtures/core__media-text__video.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__media-text__video.server.html @@ -5,7 +5,7 @@
-

My Content

+

My Content

diff --git a/tests/phpunit/data/blocks/fixtures/core__paragraph__align-right.server.html b/tests/phpunit/data/blocks/fixtures/core__paragraph__align-right.server.html index 1db164ca1fb5b..4c6b5d6ffad0a 100644 --- a/tests/phpunit/data/blocks/fixtures/core__paragraph__align-right.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__paragraph__align-right.server.html @@ -1,3 +1,3 @@ -

... like this one, which is separate from the above and right aligned.

+

... like this one, which is separate from the above and right aligned.

diff --git a/tests/phpunit/includes/unregister-blocks-hooks.php b/tests/phpunit/includes/unregister-blocks-hooks.php index b67936a103c81..116b4191766bb 100644 --- a/tests/phpunit/includes/unregister-blocks-hooks.php +++ b/tests/phpunit/includes/unregister-blocks-hooks.php @@ -1,79 +1,14 @@ get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

Custom field value

', + '

Custom field value

', $content, 'The post content should show the value of the custom field . ' ); @@ -123,7 +123,7 @@ public function test_custom_field_value_is_not_shown_in_password_protected_posts remove_filter( 'post_password_required', '__return_true' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value instead of the custom field value.' ); @@ -153,7 +153,7 @@ public function test_custom_field_value_is_not_shown_in_non_viewable_posts() { remove_filter( 'is_post_status_viewable', '__return_false' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value instead of the custom field value.' ); @@ -168,7 +168,7 @@ public function test_binding_to_non_existing_meta_key() { $content = $this->get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value.' ); @@ -183,7 +183,7 @@ public function test_binding_without_key_renders_the_fallback() { $content = $this->get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value.' ); @@ -209,7 +209,7 @@ public function test_protected_field_value_is_not_shown() { $content = $this->get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value instead of the protected value.' ); @@ -235,7 +235,7 @@ public function test_custom_field_not_exposed_in_rest_api_is_not_shown() { $content = $this->get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

Fallback value

', + '

Fallback value

', $content, 'The post content should show the fallback value instead of the protected value.' ); @@ -261,7 +261,7 @@ public function test_custom_field_with_unsafe_html_is_sanitized() { $content = $this->get_modified_post_content( '

Fallback value

' ); $this->assertSame( - '

alert(“Unsafe HTML”)

', + '

alert(“Unsafe HTML”)

', $content, 'The post content should not include the script tag.' ); @@ -298,7 +298,7 @@ public function test_filter_block_bindings_source_value() { remove_filter( 'block_bindings_source_value', $filter_value ); $this->assertSame( - '

Filtered value: tests_filter_field

', + '

Filtered value: tests_filter_field

', $content, 'The post content should show the filtered value.' ); diff --git a/tests/phpunit/tests/block-bindings/render.php b/tests/phpunit/tests/block-bindings/render.php index fa8178cbd39b2..77b0975105dc5 100644 --- a/tests/phpunit/tests/block-bindings/render.php +++ b/tests/phpunit/tests/block-bindings/render.php @@ -90,7 +90,7 @@ public function data_update_block_with_value_from_source() { HTML , - '

test source value

', + '

test source value

', ), 'button block' => array( 'text', @@ -179,19 +179,19 @@ function ( $source_args, $block_instance, $attribute_name ) { $value = $source_args['key']; return "The attribute name is '$attribute_name' and its binding has argument 'key' with value '$value'."; }, - "

The attribute name is 'content' and its binding has argument 'key' with value 'test'.

", + "

The attribute name is 'content' and its binding has argument 'key' with value 'test'.

", ), 'unsafe HTML should be sanitized' => array( function () { return ''; }, - '

alert("Unsafe HTML")

', + '

alert("Unsafe HTML")

', ), 'symbols and numbers should be rendered correctly' => array( function () { return '$12.50'; }, - '

$12.50

', + '

$12.50

', ), ); } @@ -418,7 +418,7 @@ public function test_filter_block_bindings_source_value() { remove_filter( 'block_bindings_source_value', $filter_value ); $this->assertSame( - '

Filtered value: test_arg. Block instance: core/paragraph. Attribute name: content.

', + '

Filtered value: test_arg. Block instance: core/paragraph. Attribute name: content.

', trim( $result ), 'The block content should show the filtered value.' ); diff --git a/tests/phpunit/tests/blocks/render.php b/tests/phpunit/tests/blocks/render.php index 84b2382a4affe..7b20dac147601 100644 --- a/tests/phpunit/tests/blocks/render.php +++ b/tests/phpunit/tests/blocks/render.php @@ -75,6 +75,9 @@ public function test_the_content() { // Block rendering add some extra blank lines, but we're not worried about them. $block_filtered_content = preg_replace( "/\n{2,}/", "\n", $block_filtered_content ); + // Paragraph blocks now add a class, strip it for comparison with classic content. + $block_filtered_content = str_replace( ' class="wp-block-paragraph"', '', $block_filtered_content ); + remove_shortcode( 'someshortcode' ); $this->assertSame( trim( $classic_filtered_content ), trim( $block_filtered_content ) ); diff --git a/tests/phpunit/tests/blocks/renderReusable.php b/tests/phpunit/tests/blocks/renderReusable.php index 0a88818394780..f38ae41a41173 100644 --- a/tests/phpunit/tests/blocks/renderReusable.php +++ b/tests/phpunit/tests/blocks/renderReusable.php @@ -83,7 +83,7 @@ public function test_render() { ); $block = new WP_Block( $parsed_block ); $output = $block->render(); - $this->assertSame( '

Hello world!

', $output ); + $this->assertSame( '

Hello world!

', $output ); } /** @@ -99,7 +99,7 @@ public function test_render_subsequent() { $block = new WP_Block( $parsed_block ); $output = $block->render(); $output .= $block->render(); - $this->assertSame( '

Hello world!

Hello world!

', $output ); + $this->assertSame( '

Hello world!

Hello world!

', $output ); } public function test_ref_empty() { diff --git a/tests/phpunit/tests/formatting/excerptRemoveBlocks.php b/tests/phpunit/tests/formatting/excerptRemoveBlocks.php index 1f07596903fbf..2097c35bbf5b8 100644 --- a/tests/phpunit/tests/formatting/excerptRemoveBlocks.php +++ b/tests/phpunit/tests/formatting/excerptRemoveBlocks.php @@ -12,7 +12,7 @@ class Tests_Formatting_ExcerptRemoveBlocks extends WP_UnitTestCase { public $content = ' -

paragraph

+

paragraph

@@ -25,7 +25,7 @@ class Tests_Formatting_ExcerptRemoveBlocks extends WP_UnitTestCase { -

paragraph inside column

+

paragraph inside column

@@ -35,12 +35,12 @@ class Tests_Formatting_ExcerptRemoveBlocks extends WP_UnitTestCase { public $filtered_content = ' -

paragraph

+

paragraph

-

paragraph inside column

+

paragraph inside column

'; diff --git a/tests/phpunit/tests/post/output.php b/tests/phpunit/tests/post/output.php index a08f7524eefab..c1d04303161ab 100644 --- a/tests/phpunit/tests/post/output.php +++ b/tests/phpunit/tests/post/output.php @@ -202,13 +202,13 @@ public function test_the_content_should_handle_more_block_on_singular() { $expected_without_teaser = << -

Second block.

+

Second block.

EOF; $expected_with_teaser = <<Teaser part.

+

Teaser part.

-

Second block.

+

Second block.

EOF; $this->go_to( get_permalink( $post_id ) ); @@ -253,7 +253,7 @@ public function test_the_content_should_handle_more_block_when_noteaser_on_singu $expected = << -

Second block.

+

Second block.

EOF; $this->go_to( get_permalink( $post_id ) ); diff --git a/tests/phpunit/tests/rest-api/rest-widgets-controller.php b/tests/phpunit/tests/rest-api/rest-widgets-controller.php index 246ad3b61eb09..27a58eb638f9c 100644 --- a/tests/phpunit/tests/rest-api/rest-widgets-controller.php +++ b/tests/phpunit/tests/rest-api/rest-widgets-controller.php @@ -406,7 +406,7 @@ public function test_get_items() { 'id' => 'block-1', 'id_base' => 'block', 'sidebar' => 'sidebar-1', - 'rendered' => '

Block test

', + 'rendered' => '

Block test

', ), array( 'id' => 'rss-1', diff --git a/tools/gutenberg/copy-gutenberg-build.js b/tools/gutenberg/copy-gutenberg-build.js index f123d3776617a..e5332f806f74a 100644 --- a/tools/gutenberg/copy-gutenberg-build.js +++ b/tools/gutenberg/copy-gutenberg-build.js @@ -259,6 +259,25 @@ function copyBlockAssets( config ) { const content = fs.readFileSync( blockPhpSrc, 'utf8' ); fs.writeFileSync( phpDest, content ); } + + // 4. Copy PHP subdirectories from packages (e.g., shared/helpers.php) + const blockPhpDir = path.join( phpSrc, blockName ); + if ( fs.existsSync( blockPhpDir ) ) { + const rootIndex = path.join( blockPhpDir, 'index.php' ); + fs.cpSync( blockPhpDir, blockDest, { + recursive: true, + filter: function hasPhpFiles( src ) { + const stat = fs.statSync( src ); + if ( stat.isDirectory() ) { + return fs.readdirSync( src, { withFileTypes: true } ).some( + ( entry ) => hasPhpFiles( path.join( src, entry.name ) ) + ); + } + // Copy PHP files, but skip root index.php (handled by step 3) + return src.endsWith( '.php' ) && src !== rootIndex; + }, + } ); + } } console.log( From 9d9a0f11106d80507e4d4c95864ee507b5ba3083 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 10 Feb 2026 00:57:51 +0000 Subject: [PATCH 016/147] Site Health: Account for missing or slashed `$_SERVER` data in `WP_Debug_Data`. Developed in https://github.com/WordPress/wordpress-develop/pull/10870 Follow-up to [56056], [45156], [44986] Props vishalkakadiya, sabernhardt, peterwilsoncc, mukesh27, westonruter. Fixes #64599. git-svn-id: https://develop.svn.wordpress.org/trunk@61606 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-debug-data.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php index aa0f3eb10ce45..e7e90622dca12 100644 --- a/src/wp-admin/includes/class-wp-debug-data.php +++ b/src/wp-admin/includes/class-wp-debug-data.php @@ -373,8 +373,8 @@ private static function get_wp_server(): array { ); $fields['httpd_software'] = array( 'label' => __( 'Web server' ), - 'value' => $_SERVER['SERVER_SOFTWARE'] ?? __( 'Unable to determine what web server software is used' ), - 'debug' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', + 'value' => ! empty( $_SERVER['SERVER_SOFTWARE'] ) ? wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) : __( 'Unable to determine what web server software is used' ), + 'debug' => ! empty( $_SERVER['SERVER_SOFTWARE'] ) ? wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) : 'unknown', ); $fields['php_version'] = array( 'label' => __( 'PHP version' ), @@ -545,7 +545,7 @@ private static function get_wp_server(): array { ); $fields['server-time'] = array( 'label' => __( 'Current Server time' ), - 'value' => wp_date( 'c', $_SERVER['REQUEST_TIME'] ), + 'value' => isset( $_SERVER['REQUEST_TIME'] ) ? wp_date( 'c', (int) $_SERVER['REQUEST_TIME'] ) : __( 'Unable to determine server time' ), ); return array( From e9f34bd1c1b8aa8e6aea904d904ceac43ae3146a Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Tue, 10 Feb 2026 15:19:36 +0000 Subject: [PATCH 017/147] Editor: Add support for pseudo elements for the block and its variations on theme.json. Adds support for pseudo elements on the core/button block for ( ':hover', ':focus', ':focus-visible', ':active' ) at the theme.json level. This is also allowing the block's variations to control the same pseudo elements, so now we can style hover for the outline variation too. Example usage: {{{ "styles": { "blocks": { "core/button": { "color": { "background": "blue" }, ":hover": { "color": { "background": "green" } }, ":focus": { "color": { "background": "purple" } }, "variations": { "outline": { ":hover": { "color": { "background": "pink" } } } } } } } }}} Reviewed by palak678, getdave, MaggieCabrera. Props MaggieCabrera, scruffian, palak678. joedolson, getdave, mikachan. Fixes #64263. git-svn-id: https://develop.svn.wordpress.org/trunk@61607 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-theme-json.php | 107 ++++++++- .../global-styles-and-settings.php | 4 +- tests/phpunit/tests/theme/wpThemeJson.php | 216 +++++++++++++++++- 3 files changed, 322 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 3e7f6f3f78475..37134501dc1b3 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -609,6 +609,16 @@ class WP_Theme_JSON { 'button' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), ); + /** + * The valid pseudo-selectors that can be used for blocks. + * + * @since 7.0 + * @var array + */ + const VALID_BLOCK_PSEUDO_SELECTORS = array( + 'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ), + ); + /** * The valid elements that can be found under styles. * @@ -699,6 +709,35 @@ protected static function schema_in_root_and_per_origin( $schema ) { return $schema_in_root_and_per_origin; } + + /** + * Processes pseudo-selectors for any node (block or variation). + * + * @param array $node The node data (block or variation). + * @param string $base_selector The base selector. + * @param array $settings The theme settings. + * @param string $block_name The block name. + * @return array Array of pseudo-selector declarations. + */ + private static function process_pseudo_selectors( $node, $base_selector, $settings, $block_name ) { + $pseudo_declarations = array(); + + if ( ! isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + return $pseudo_declarations; + } + + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $node[ $pseudo_selector ] ) ) { + $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); + $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, null ); + $pseudo_declarations[ $combined_selector ] = $declarations; + } + } + + return $pseudo_declarations; + } + + /** * Returns a class name by an element name. * @@ -1018,6 +1057,13 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_settings_blocks[ $block ] = static::VALID_SETTINGS; $schema_styles_blocks[ $block ] = $styles_non_top_level; $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements; + + // Add pseudo-selectors for blocks that support them + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $schema_styles_blocks[ $block ][ $pseudo_selector ] = $styles_non_top_level; + } + } } $block_style_variation_styles = static::VALID_STYLES; @@ -1040,7 +1086,18 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_variations = array(); if ( ! empty( $style_variation_names ) ) { - $schema_styles_variations = array_fill_keys( $style_variation_names, $block_style_variation_styles ); + foreach ( $style_variation_names as $variation_name ) { + $variation_schema = $block_style_variation_styles; + + // Add pseudo-selectors to variations for blocks that support them + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $variation_schema[ $pseudo_selector ] = $styles_non_top_level; + } + } + + $schema_styles_variations[ $variation_name ] = $variation_schema; + } } $schema_styles_blocks[ $block ]['variations'] = $schema_styles_variations; @@ -2742,6 +2799,23 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'variations' => $variation_selectors, 'css' => $selector, ); + + // Handle any pseudo selectors for the block. + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), + 'selector' => static::append_to_selector( $selector, $pseudo_selector ), + 'selectors' => $feature_selectors, + 'duotone' => $duotone_selector, + 'variations' => $variation_selectors, + 'css' => static::append_to_selector( $selector, $pseudo_selector ), + ); + } + } + } } if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { @@ -2838,6 +2912,12 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { // Compute declarations for remaining styles not covered by feature level selectors. $style_variation_declarations[ $style_variation['selector'] ] = static::compute_style_properties( $style_variation_node, $settings, null, $this->theme_json ); + + // Process pseudo-selectors for this variation (e.g., :hover, :focus) + $block_name = isset( $block_metadata['name'] ) ? $block_metadata['name'] : ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ? $block_metadata['path'][2] : null ); + $variation_pseudo_declarations = static::process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name ); + $style_variation_declarations = array_merge( $style_variation_declarations, $variation_pseudo_declarations ); + // Store custom CSS for the style variation. if ( isset( $style_variation_node['css'] ) ) { $style_variation_custom_css[ $style_variation['selector'] ] = $this->process_blocks_custom_css( $style_variation_node['css'], $style_variation['selector'] ); @@ -2872,6 +2952,23 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $element_pseudo_allowed = static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ]; } + /* + * Check if we're processing a block pseudo-selector. + * $block_metadata['path'] = array( 'styles', 'blocks', 'core/button', ':hover' ); + */ + $is_processing_block_pseudo = false; + $block_pseudo_selector = null; + if ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 4 ) { + $block_name = $block_metadata['path'][2]; // 'core/button' + $last_path_element = $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ]; // ':hover' + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) && + in_array( $last_path_element, static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ], true ) ) { + $is_processing_block_pseudo = true; + $block_pseudo_selector = $last_path_element; + } + } + /* * Check for allowed pseudo classes (e.g. ":hover") from the $selector ("a:hover"). * This also resets the array keys. @@ -2901,6 +2998,14 @@ static function ( $pseudo_selector ) use ( $selector ) { && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true ) ) { $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding ); + } elseif ( $is_processing_block_pseudo ) { + // Process block pseudo-selector styles + // For block pseudo-selectors, we need to get the block data first, then access the pseudo-selector + $block_name = $block_metadata['path'][2]; // 'core/button' + $block_data = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $block_name ), array() ); + $pseudo_data = isset( $block_data[ $block_pseudo_selector ] ) ? $block_data[ $block_pseudo_selector ] : array(); + + $declarations = static::compute_style_properties( $pseudo_data, $settings, null, $this->theme_json, $selector, $use_root_padding ); } else { $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding ); } diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index d50ee14e22015..4b08301f9c7db 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -276,8 +276,8 @@ function wp_add_global_styles_for_blocks() { foreach ( $block_nodes as $metadata ) { if ( $can_use_cached ) { - // Use the block name as the key for cached CSS data. Otherwise, use a hash of the metadata. - $cache_node_key = $metadata['name'] ?? md5( wp_json_encode( $metadata ) ); + // Generate a unique cache key based on the full metadata to ensure pseudo-selectors and other variations get unique keys. + $cache_node_key = md5( wp_json_encode( $metadata ) ); if ( isset( $cached['blocks'][ $cache_node_key ] ) ) { $block_css = $cached['blocks'][ $cache_node_key ]; diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index b26ff2b9a9c4c..965210a80afbe 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -6069,8 +6069,8 @@ public function test_internal_syntax_is_converted_to_css_variables() { * @ticket 58588 * @ticket 60613 * - * @covers WP_Theme_JSON_Gutenberg::resolve_variables - * @covers WP_Theme_JSON_Gutenberg::convert_variables_to_value + * @covers WP_Theme_JSON::resolve_variables + * @covers WP_Theme_JSON::convert_variables_to_value */ public function test_resolve_variables() { $primary_color = '#9DFF20'; @@ -6693,4 +6693,216 @@ public function test_merge_incoming_data_unique_slugs_always_preserved() { $this->assertEqualSetsWithIndex( $expected, $actual ); } + + /** + * Test that block pseudo selectors are processed correctly. + */ + public function test_block_pseudo_selectors_are_processed() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + ':focus' => array( + 'color' => array( + 'text' => 'red', + 'background' => 'yellow', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}:root :where(.wp-block-button .wp-block-button__link:focus){background-color: yellow;color: red;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Test that block pseudo selectors are processed correctly within variations. + */ + public function test_block_variation_pseudo_selectors_are_processed() { + register_block_style( + 'core/button', + array( + 'name' => 'outline', + 'label' => 'Outline', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + 'variations' => array( + 'outline' => array( + 'color' => array( + 'text' => 'currentColor', + 'background' => 'transparent', + ), + 'border' => array( + 'color' => 'currentColor', + 'width' => '1px', + 'style' => 'solid', + ), + ':hover' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'red', + ), + ), + ':focus' => array( + 'color' => array( + 'text' => 'black', + 'background' => 'yellow', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link){background-color: transparent;border-color: currentColor;border-width: 1px;border-style: solid;color: currentColor;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link:hover){background-color: red;color: white;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link:focus){background-color: yellow;color: black;}'; + $actual = $theme_json->get_stylesheet( + array( 'styles' ), + null, + array( + 'skip_root_layout_styles' => true, + 'include_block_style_variations' => true, + ) + ); + + unregister_block_style( 'core/button', 'outline' ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Test that non-whitelisted pseudo selectors are ignored for blocks. + */ + public function test_block_pseudo_selectors_ignores_non_whitelisted() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + ':levitate' => array( + 'color' => array( + 'text' => 'yellow', + 'background' => 'black', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + $this->assertStringNotContainsString( '.wp-block-button .wp-block-button__link:levitate{', $theme_json->get_stylesheet( array( 'styles' ) ) ); + } + + /** + * Test that blocks without pseudo selector support ignore pseudo selectors. + */ + public function test_blocks_without_pseudo_support_ignore_pseudo_selectors() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/paragraph' => array( + 'color' => array( + 'text' => 'black', + ), + ':hover' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(p){color: black;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + $this->assertStringNotContainsString( 'p:hover{', $theme_json->get_stylesheet( array( 'styles' ) ) ); + } + + /** + * Test that block pseudo selectors work with elements within blocks. + */ + public function test_block_pseudo_selectors_with_elements() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + 'elements' => array( + 'button' => array( + 'color' => array( + 'text' => 'green', + ), + ':hover' => array( + 'color' => array( + 'text' => 'orange', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}:root :where(.wp-block-button .wp-block-button__link .wp-element-button,.wp-block-button .wp-block-button__link .wp-block-button__link){color: green;}:root :where(.wp-block-button .wp-block-button__link .wp-element-button:hover,.wp-block-button .wp-block-button__link .wp-block-button__link:hover){color: orange;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } } From 3dbdf1aba9db20583a83d9960a30f4a82bc55fd9 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 10 Feb 2026 16:05:25 +0000 Subject: [PATCH 018/147] Twenty Twenty-One: Remove redundant comment for conditionally-defined function. This also improves consistency with other themes. Follow-up to [49216], [53121], [61552], [61272]. Props huzaifaalmesbah, sabernhardt. See #64226. git-svn-id: https://develop.svn.wordpress.org/trunk@61608 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-content/themes/twentytwentyone/inc/block-patterns.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/wp-content/themes/twentytwentyone/inc/block-patterns.php b/src/wp-content/themes/twentytwentyone/inc/block-patterns.php index 2eeaeed6d7f95..4533ed5aa46b8 100644 --- a/src/wp-content/themes/twentytwentyone/inc/block-patterns.php +++ b/src/wp-content/themes/twentytwentyone/inc/block-patterns.php @@ -27,12 +27,9 @@ function twenty_twenty_one_register_block_pattern_category() { add_action( 'init', 'twenty_twenty_one_register_block_pattern_category' ); } -/** - * Register Block Patterns. - */ if ( function_exists( 'register_block_pattern' ) ) { /** - * Registers Block Pattern. + * Registers Block Patterns. * * @since Twenty Twenty-One 1.0 * From db26faa1574bbbf7c47cf4d118c8032be37febf4 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Tue, 10 Feb 2026 16:36:25 +0000 Subject: [PATCH 019/147] Editor: Navigation overlay - patterns and template part definition. Adds a new template part for the Navigation block called WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY, corresponding area definition in block-template-utils.php to support navigation overlay template parts. Also adds the navigation block pattern category registration in block-patterns.php and five new navigation overlay block patterns. Reviewed by mikachan, get_dave. Props onemaggie, scruffian, get_dave, mikachan, wildworks. Fixes #64589. git-svn-id: https://develop.svn.wordpress.org/trunk@61609 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/block-patterns.php | 12 +++++ .../navigation-overlay-accent-bg.php | 29 ++++++++++++ .../navigation-overlay-black-bg.php | 19 ++++++++ ...avigation-overlay-centered-with-extras.php | 45 +++++++++++++++++++ .../navigation-overlay-centered.php | 21 +++++++++ .../block-patterns/navigation-overlay.php | 19 ++++++++ src/wp-includes/block-template-utils.php | 12 +++++ 7 files changed, 157 insertions(+) create mode 100644 src/wp-includes/block-patterns/navigation-overlay-accent-bg.php create mode 100644 src/wp-includes/block-patterns/navigation-overlay-black-bg.php create mode 100644 src/wp-includes/block-patterns/navigation-overlay-centered-with-extras.php create mode 100644 src/wp-includes/block-patterns/navigation-overlay-centered.php create mode 100644 src/wp-includes/block-patterns/navigation-overlay.php diff --git a/src/wp-includes/block-patterns.php b/src/wp-includes/block-patterns.php index 133c6d54ea33a..50ca1a426378d 100644 --- a/src/wp-includes/block-patterns.php +++ b/src/wp-includes/block-patterns.php @@ -79,6 +79,11 @@ function _register_core_block_patterns_and_categories() { 'query-grid-posts', 'query-large-title-posts', 'query-offset-posts', + 'navigation-overlay', + 'navigation-overlay-black-bg', + 'navigation-overlay-accent-bg', + 'navigation-overlay-centered', + 'navigation-overlay-centered-with-extras', ); foreach ( $core_block_patterns as $core_block_pattern ) { @@ -228,6 +233,13 @@ function _register_core_block_patterns_and_categories() { 'description' => __( 'A variety of header designs displaying your site title and navigation.' ), ) ); + register_block_pattern_category( + 'navigation', + array( + 'label' => _x( 'Navigation', 'Block pattern category' ), + 'description' => __( 'A variety of designs displaying site navigation.' ), + ) + ); } /** diff --git a/src/wp-includes/block-patterns/navigation-overlay-accent-bg.php b/src/wp-includes/block-patterns/navigation-overlay-accent-bg.php new file mode 100644 index 0000000000000..f9139f38fc655 --- /dev/null +++ b/src/wp-includes/block-patterns/navigation-overlay-accent-bg.php @@ -0,0 +1,29 @@ + _x( 'Overlay with orange background', 'Block pattern title' ), + 'blockTypes' => array( 'core/template-part/navigation-overlay' ), + 'categories' => array( 'navigation' ), + 'content' => ' + +', +); diff --git a/src/wp-includes/block-patterns/navigation-overlay-black-bg.php b/src/wp-includes/block-patterns/navigation-overlay-black-bg.php new file mode 100644 index 0000000000000..0ec87fff322e8 --- /dev/null +++ b/src/wp-includes/block-patterns/navigation-overlay-black-bg.php @@ -0,0 +1,19 @@ + _x( 'Overlay with black background', 'Block pattern title' ), + 'blockTypes' => array( 'core/template-part/navigation-overlay' ), + 'categories' => array( 'navigation' ), + 'content' => ' + +', +); diff --git a/src/wp-includes/block-patterns/navigation-overlay-centered-with-extras.php b/src/wp-includes/block-patterns/navigation-overlay-centered-with-extras.php new file mode 100644 index 0000000000000..14748a4331bd2 --- /dev/null +++ b/src/wp-includes/block-patterns/navigation-overlay-centered-with-extras.php @@ -0,0 +1,45 @@ + _x( 'Overlay with site info and CTA', 'Block pattern title' ), + 'blockTypes' => array( 'core/template-part/navigation-overlay' ), + 'categories' => array( 'navigation' ), + 'content' => ' + +', +); diff --git a/src/wp-includes/block-patterns/navigation-overlay-centered.php b/src/wp-includes/block-patterns/navigation-overlay-centered.php new file mode 100644 index 0000000000000..3428aabf5011d --- /dev/null +++ b/src/wp-includes/block-patterns/navigation-overlay-centered.php @@ -0,0 +1,21 @@ + _x( 'Overlay with centered navigation', 'Block pattern title' ), + 'blockTypes' => array( 'core/template-part/navigation-overlay' ), + 'categories' => array( 'navigation' ), + 'content' => ' + +', +); diff --git a/src/wp-includes/block-patterns/navigation-overlay.php b/src/wp-includes/block-patterns/navigation-overlay.php new file mode 100644 index 0000000000000..c979973a82684 --- /dev/null +++ b/src/wp-includes/block-patterns/navigation-overlay.php @@ -0,0 +1,19 @@ + _x( 'Navigation Overlay', 'Block pattern title' ), + 'blockTypes' => array( 'core/template-part/navigation-overlay' ), + 'categories' => array( 'navigation' ), + 'content' => ' +
+
+ + +
+', +); diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index df016c4a1d0fa..ed23647ab01d0 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -19,6 +19,9 @@ if ( ! defined( 'WP_TEMPLATE_PART_AREA_UNCATEGORIZED' ) ) { define( 'WP_TEMPLATE_PART_AREA_UNCATEGORIZED', 'uncategorized' ); } +if ( ! defined( 'WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY' ) ) { + define( 'WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY', 'navigation-overlay' ); +} /** * For backward compatibility reasons, @@ -96,6 +99,15 @@ function get_allowed_block_template_part_areas() { 'icon' => 'footer', 'area_tag' => 'footer', ), + array( + 'area' => WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY, + 'label' => _x( 'Navigation Overlay', 'template part area' ), + 'description' => __( + 'The Navigation Overlay template defines a full-screen overlay area that typically contains navigation links and can be toggled on and off.' + ), + 'icon' => 'overlay', + 'area_tag' => 'div', + ), ); /** From 163bc042c5753f204c9d7400c80e67ce85e56796 Mon Sep 17 00:00:00 2001 From: Joe Dolson Date: Tue, 10 Feb 2026 19:51:56 +0000 Subject: [PATCH 020/147] Login and Registration: Populate username after password reset. Accessibility: to meet WCAG 2.2/3.3.7: Redundant entry, the username should be auto-populated when a user performs a password reset. There is an existing cookie set that contains this information, but was deleted before displaying the login form. Move cookie deletion to occur after displaying login form and use to set `$user_login`. Props estelaris, alh0319, sabernhardt, oglekler, peterwilsoncc, rcreators, rishavdutta, chaion07, stoyangeorgiev, rinkalpagdar, pratiklondhe, lukasfritzedev, ferdoused, audrasjb, westonruter, joedolson. Fixes #60726. git-svn-id: https://develop.svn.wordpress.org/trunk@61610 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-login.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wp-login.php b/src/wp-login.php index c9db31826bbdb..4bd2284c5244c 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1000,7 +1000,6 @@ function wp_login_viewport_meta() { if ( ( ! $errors->has_errors() ) && isset( $_POST['pass1'] ) && ! empty( $_POST['pass1'] ) ) { reset_password( $user, $_POST['pass1'] ); - setcookie( $rp_cookie, ' ', time() - YEAR_IN_SECONDS, $rp_path, COOKIE_DOMAIN, is_ssl(), true ); login_header( __( 'Password Reset' ), wp_get_admin_notice( @@ -1487,6 +1486,14 @@ function wp_login_viewport_meta() { wp_clear_auth_cookie(); } + // Obtain user from password reset cookie flow before clearing the cookie. + $rp_cookie = 'wp-resetpass-' . COOKIEHASH; + if ( isset( $_COOKIE[ $rp_cookie ] ) && is_string( $_COOKIE[ $rp_cookie ] ) ) { + $user_login = sanitize_user( strtok( wp_unslash( $_COOKIE[ $rp_cookie ] ), ':' ) ); + list( $rp_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) ); + setcookie( $rp_cookie, ' ', time() - YEAR_IN_SECONDS, $rp_path, COOKIE_DOMAIN, is_ssl(), true ); + } + login_header( __( 'Log In' ), '', $errors ); if ( isset( $_POST['log'] ) ) { From 81885a9ff64f3902d4f3162c011745a35b46e6ba Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 10 Feb 2026 22:29:49 +0000 Subject: [PATCH 021/147] Code Editor: Switch from Esprima to Espree for JavaScript linting in CodeMirror. Esprima is no longer maintained, and it does not support the latest JavaScript features in ES11, as Espree does. - **New Linter Integration:** Introduces `src/js/_enqueues/vendor/codemirror/javascript-lint.js` using `espree` for parsing and error reporting, replacing the dependency on `jshint` and `esprima` scripts. - **Script Modules:** Registers `espree` as a script module and leverages the `module_dependencies` argument in `wp_register_script()` to ensure `espree` is available as a dynamic import. - **Editor Settings:** Updates `wp_get_code_editor_settings()` to use ES11 (ECMAScript 2020) defaults and synchronizes JSHint settings from `.jshintrc` for compatibility. - **Editable Extensions:** Adds `.mjs` to the list of editable file extensions for plugins and themes. - **Deprecations:** Marks `esprima` and `jshint` script handles as deprecated. - **Build Tools:** Updates Webpack configuration to bundle `espree` as a module and use the new local `javascript-lint.js`. Developed in https://github.com/WordPress/wordpress-develop/pull/10806 Follow-up to [61587], [61544], [61539], [42547]. Props westonruter, jonsurrell. See #64562, #61500, #48456, #42850. Fixes #64558. git-svn-id: https://develop.svn.wordpress.org/trunk@61611 602fd350-edb4-49c9-b593-d223f7449a82 --- package-lock.json | 26 ++- package.json | 2 + .../vendor/codemirror/javascript-lint.js | 121 ++++++++++++++ src/wp-admin/includes/file.php | 2 + src/wp-includes/general-template.php | 60 ++++--- src/wp-includes/script-loader.php | 5 +- src/wp-includes/script-modules.php | 7 + tests/phpunit/tests/dependencies/scripts.php | 22 ++- .../tests/widgets/wpWidgetCustomHtml.php | 1 - tools/vendors/codemirror-entry.js | 153 +++++++++--------- tools/webpack/codemirror.config.js | 61 ++++--- 11 files changed, 320 insertions(+), 140 deletions(-) create mode 100644 src/js/_enqueues/vendor/codemirror/javascript-lint.js diff --git a/package-lock.json b/package-lock.json index 87af6af9dd1c2..cae57f5937eca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "core-js-url-browser": "3.6.4", "csslint": "1.0.5", "element-closest": "3.0.2", + "espree": "9.6.1", "esprima": "4.0.1", "formdata-polyfill": "4.0.10", "hoverintent": "2.2.1", @@ -44,6 +45,7 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.56.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.1", + "@types/codemirror": "5.60.17", "@wordpress/e2e-test-utils-playwright": "1.33.2", "@wordpress/prettier-config": "4.33.1", "@wordpress/scripts": "30.26.2", @@ -5131,6 +5133,16 @@ "@types/node": "*" } }, + "node_modules/@types/codemirror": { + "version": "5.60.17", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz", + "integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5420,6 +5432,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.3.tgz", @@ -7445,7 +7467,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7468,7 +7489,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -13285,7 +13305,6 @@ "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -13302,7 +13321,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" diff --git a/package.json b/package.json index 66c8e0b5b23af..766e241ff8d6d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.56.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.1", + "@types/codemirror": "5.60.17", "@wordpress/e2e-test-utils-playwright": "1.33.2", "@wordpress/prettier-config": "4.33.1", "@wordpress/scripts": "30.26.2", @@ -79,6 +80,7 @@ "core-js-url-browser": "3.6.4", "csslint": "1.0.5", "element-closest": "3.0.2", + "espree": "9.6.1", "esprima": "4.0.1", "formdata-polyfill": "4.0.10", "hoverintent": "2.2.1", diff --git a/src/js/_enqueues/vendor/codemirror/javascript-lint.js b/src/js/_enqueues/vendor/codemirror/javascript-lint.js new file mode 100644 index 0000000000000..3f98ad523346c --- /dev/null +++ b/src/js/_enqueues/vendor/codemirror/javascript-lint.js @@ -0,0 +1,121 @@ +/** + * CodeMirror JavaScript linter. + * + * @since 7.0.0 + */ + +import CodeMirror from 'codemirror'; + +/** + * CodeMirror Lint Error. + * + * @see https://codemirror.net/5/doc/manual.html#addon_lint + * + * @typedef {Object} CodeMirrorLintError + * @property {string} message - Error message. + * @property {'error'} severity - Severity. + * @property {CodeMirror.Position} from - From position. + * @property {CodeMirror.Position} to - To position. + */ + +/** + * JSHint options supported by Espree. + * + * @see https://jshint.com/docs/options/ + * @see https://www.npmjs.com/package/espree#options + * + * @typedef {Object} SupportedJSHintOptions + * @property {number} [esversion] - "This option is used to specify the ECMAScript version to which the code must adhere." + * @property {boolean} [es5] - "This option enables syntax first defined in the ECMAScript 5.1 specification. This includes allowing reserved keywords as object properties." + * @property {boolean} [es3] - "This option tells JSHint that your code needs to adhere to ECMAScript 3 specification. Use this option if you need your program to be executable in older browsers—such as Internet Explorer 6/7/8/9—and other legacy JavaScript environments." + * @property {boolean} [module] - "This option informs JSHint that the input code describes an ECMAScript 6 module. All module code is interpreted as strict mode code." + * @property {'implied'} [strict] - "This option requires the code to run in ECMAScript 5's strict mode." + */ + +/** + * Validates JavaScript. + * + * @since 7.0.0 + * + * @param {string} text - Source. + * @param {SupportedJSHintOptions} options - Linting options. + * @returns {Promise} + */ +async function validator( text, options ) { + const errors = /** @type {CodeMirrorLintError[]} */ []; + try { + const espree = await import( /* webpackIgnore: true */ 'espree' ); + espree.parse( text, { + ...getEspreeOptions( options ), + loc: true, + } ); + } catch ( error ) { + if ( + // This is an `EnhancedSyntaxError` in Espree: . + error instanceof SyntaxError && + typeof error.lineNumber === 'number' && + typeof error.column === 'number' + ) { + const line = error.lineNumber - 1; + errors.push( { + message: error.message, + severity: 'error', + from: CodeMirror.Pos( line, error.column - 1 ), + to: CodeMirror.Pos( line, error.column ), + } ); + } else { + console.warn( '[CodeMirror] Unable to lint JavaScript:', error ); // jshint ignore:line + } + } + + return errors; +} + +CodeMirror.registerHelper( 'lint', 'javascript', validator ); + +/** + * Gets the options for Espree from the supported JSHint options. + * + * @since 7.0.0 + * + * @param {SupportedJSHintOptions} options - Linting options for JSHint. + * @return {{ + * ecmaVersion?: number|'latest', + * ecmaFeatures?: { + * impliedStrict?: true + * } + * }} + */ +function getEspreeOptions( options ) { + const ecmaFeatures = {}; + if ( options.strict === 'implied' ) { + ecmaFeatures.impliedStrict = true; + } + + return { + ecmaVersion: getEcmaVersion( options ), + sourceType: options.module ? 'module' : 'script', + ecmaFeatures, + }; +} + +/** + * Gets the ECMAScript version. + * + * @since 7.0.0 + * + * @param {SupportedJSHintOptions} options - Options. + * @return {number|'latest'} ECMAScript version. + */ +function getEcmaVersion( options ) { + if ( typeof options.esversion === 'number' ) { + return options.esversion; + } + if ( options.es5 ) { + return 5; + } + if ( options.es3 ) { + return 3; + } + return 'latest'; +} diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 610125b252ec0..09d080da2dd6e 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -202,6 +202,7 @@ function wp_get_plugin_file_editable_extensions( $plugin ) { 'inc', 'include', 'js', + 'mjs', 'json', 'jsx', 'less', @@ -261,6 +262,7 @@ function wp_get_theme_file_editable_extensions( $theme ) { 'inc', 'include', 'js', + 'mjs', 'json', 'jsx', 'less', diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index f5dacf28f7327..7afab9f8c059d 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4069,7 +4069,6 @@ function wp_enqueue_code_editor( $args ) { case 'text/x-php': wp_enqueue_script( 'htmlhint' ); wp_enqueue_script( 'csslint' ); - wp_enqueue_script( 'jshint' ); if ( ! current_user_can( 'unfiltered_html' ) ) { wp_enqueue_script( 'htmlhint-kses' ); } @@ -4081,7 +4080,6 @@ function wp_enqueue_code_editor( $args ) { case 'application/ld+json': case 'text/typescript': case 'application/typescript': - wp_enqueue_script( 'jshint' ); wp_enqueue_script( 'jsonlint' ); break; } @@ -4153,30 +4151,39 @@ function wp_get_code_editor_settings( $args ) { 'outline-none' => true, ), 'jshint' => array( - // The following are copied from . - 'boss' => true, - 'curly' => true, - 'eqeqeq' => true, - 'eqnull' => true, - 'es3' => true, - 'expr' => true, - 'immed' => true, - 'noarg' => true, - 'nonbsp' => true, - 'onevar' => true, - 'quotmark' => 'single', - 'trailing' => true, - 'undef' => true, - 'unused' => true, - - 'browser' => true, - - 'globals' => array( - '_' => false, - 'Backbone' => false, - 'jQuery' => false, - 'JSON' => false, - 'wp' => false, + 'esversion' => 11, + 'module' => str_ends_with( $args['file'] ?? '', '.mjs' ), + + // The following JSHint *linting rule* options are copied from + // . + // Parsing-related options such as `esversion` (and, in other contexts, `es5`, `es3`, `module`, `strict`) + // are honored by the Espree-based integration, but these linting-rule options are not interpreted by Espree + // and are kept only for compatibility/documentation with the original JSHint configuration. + 'boss' => true, + 'curly' => true, + 'eqeqeq' => true, + 'eqnull' => true, + 'expr' => true, + 'immed' => true, + 'noarg' => true, + 'nonbsp' => true, + 'quotmark' => 'single', + 'undef' => true, + 'unused' => true, + 'browser' => true, + 'globals' => array( + '_' => false, + 'Backbone' => false, + 'jQuery' => false, + 'JSON' => false, + 'wp' => false, + 'export' => false, + 'module' => false, + 'require' => false, + 'WorkerGlobalScope' => false, + 'self' => false, + 'OffscreenCanvas' => false, + 'Promise' => false, ), ), 'htmlhint' => array( @@ -4233,6 +4240,7 @@ function wp_get_code_editor_settings( $args ) { $type = 'message/http'; break; case 'js': + case 'mjs': $type = 'text/javascript'; break; case 'json': diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 87689423c37ac..4e9de5a0a7ed9 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1196,9 +1196,10 @@ function wp_default_scripts( $scripts ) { ); $scripts->add( 'wp-codemirror', '/wp-includes/js/codemirror/codemirror.min.js', array(), '5.65.20' ); + did_action( 'init' ) && $scripts->add_data( 'wp-codemirror', 'module_dependencies', array( 'espree' ) ); $scripts->add( 'csslint', '/wp-includes/js/codemirror/csslint.js', array(), '1.0.5' ); - $scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); - $scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' ); + $scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated. Use 'espree' script module. + $scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' ); // Deprecated. $scripts->add( 'jsonlint', '/wp-includes/js/codemirror/jsonlint.js', array(), '1.6.3' ); $scripts->add( 'htmlhint', '/wp-includes/js/codemirror/htmlhint.js', array(), '1.8.0' ); $scripts->add( 'htmlhint-kses', '/wp-includes/js/codemirror/htmlhint-kses.js', array( 'htmlhint' ) ); diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index ee91ee4361a7d..0a39efea1dc27 100644 --- a/src/wp-includes/script-modules.php +++ b/src/wp-includes/script-modules.php @@ -194,6 +194,13 @@ function wp_default_script_modules() { $module_deps = $script_module_data['module_dependencies'] ?? array(); wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args ); } + + wp_register_script_module( + 'espree', + includes_url( 'js/codemirror/espree.min.js' ), + array(), + '9.6.1' + ); } /** diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 995d46ad6ae61..6050983cc5f5e 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -3158,14 +3158,13 @@ public function test_wp_enqueue_code_editor_when_php_file_will_be_passed() { 'curly', 'eqeqeq', 'eqnull', - 'es3', + 'esversion', 'expr', 'immed', + 'module', 'noarg', 'nonbsp', - 'onevar', 'quotmark', - 'trailing', 'undef', 'unused', 'browser', @@ -3242,14 +3241,13 @@ public function test_wp_enqueue_code_editor_when_generated_array_by_compact_will 'curly', 'eqeqeq', 'eqnull', - 'es3', + 'esversion', 'expr', 'immed', + 'module', 'noarg', 'nonbsp', - 'onevar', 'quotmark', - 'trailing', 'undef', 'unused', 'browser', @@ -3340,14 +3338,13 @@ public function test_wp_enqueue_code_editor_when_generated_array_by_array_merge_ 'curly', 'eqeqeq', 'eqnull', - 'es3', + 'esversion', 'expr', 'immed', + 'module', 'noarg', 'nonbsp', - 'onevar', 'quotmark', - 'trailing', 'undef', 'unused', 'browser', @@ -3435,14 +3432,13 @@ public function test_wp_enqueue_code_editor_when_simple_array_will_be_passed() { 'curly', 'eqeqeq', 'eqnull', - 'es3', + 'esversion', 'expr', 'immed', + 'module', 'noarg', 'nonbsp', - 'onevar', 'quotmark', - 'trailing', 'undef', 'unused', 'browser', @@ -4020,7 +4016,7 @@ static function ( $dependency ) { ); // Exclude packages that are not registered in WordPress. - $exclude = array( 'react-is', 'json2php' ); + $exclude = array( 'react-is', 'json2php', 'espree' ); $package_json_dependencies = array_diff( $package_json_dependencies, $exclude ); /* diff --git a/tests/phpunit/tests/widgets/wpWidgetCustomHtml.php b/tests/phpunit/tests/widgets/wpWidgetCustomHtml.php index 1a61d944719b6..c9377ba54e655 100644 --- a/tests/phpunit/tests/widgets/wpWidgetCustomHtml.php +++ b/tests/phpunit/tests/widgets/wpWidgetCustomHtml.php @@ -251,7 +251,6 @@ public function test_enqueue_admin_scripts_when_logged_in_and_syntax_highlightin $this->assertTrue( wp_script_is( 'code-editor', 'enqueued' ) ); $this->assertTrue( wp_script_is( 'wp-codemirror', 'enqueued' ) ); $this->assertTrue( wp_script_is( 'csslint', 'enqueued' ) ); - $this->assertTrue( wp_script_is( 'jshint', 'enqueued' ) ); $this->assertTrue( wp_script_is( 'htmlhint', 'enqueued' ) ); } diff --git a/tools/vendors/codemirror-entry.js b/tools/vendors/codemirror-entry.js index cf3b7523d0edf..a8856f55d11da 100644 --- a/tools/vendors/codemirror-entry.js +++ b/tools/vendors/codemirror-entry.js @@ -1,90 +1,91 @@ // Import CodeMirror core to be exposed as window.wp.CodeMirror. -var CodeMirror = require( 'codemirror/lib/codemirror' ); +import CodeMirror from 'codemirror/lib/codemirror'; // Keymaps -require( 'codemirror/keymap/emacs' ); -require( 'codemirror/keymap/sublime' ); -require( 'codemirror/keymap/vim' ); +import 'codemirror/keymap/emacs'; +import 'codemirror/keymap/sublime'; +import 'codemirror/keymap/vim'; // Addons (Hinting) -require( 'codemirror/addon/hint/show-hint' ); -require( 'codemirror/addon/hint/anyword-hint' ); -require( 'codemirror/addon/hint/css-hint' ); -require( 'codemirror/addon/hint/html-hint' ); -require( 'codemirror/addon/hint/javascript-hint' ); -require( 'codemirror/addon/hint/sql-hint' ); -require( 'codemirror/addon/hint/xml-hint' ); +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/hint/anyword-hint'; +import 'codemirror/addon/hint/css-hint'; +import 'codemirror/addon/hint/html-hint'; +import 'codemirror/addon/hint/javascript-hint'; +import 'codemirror/addon/hint/sql-hint'; +import 'codemirror/addon/hint/xml-hint'; // Addons (Linting) -require( 'codemirror/addon/lint/lint' ); -require( 'codemirror/addon/lint/css-lint' ); -require( 'codemirror/addon/lint/html-lint' ); -require( 'codemirror/addon/lint/javascript-lint' ); -require( 'codemirror/addon/lint/json-lint' ); +import 'codemirror/addon/lint/lint'; +import 'codemirror/addon/lint/css-lint'; +import 'codemirror/addon/lint/html-lint'; + +import '../../src/js/_enqueues/vendor/codemirror/javascript-lint'; +import 'codemirror/addon/lint/json-lint'; // Addons (Other) -require( 'codemirror/addon/comment/comment' ); -require( 'codemirror/addon/comment/continuecomment' ); -require( 'codemirror/addon/fold/xml-fold' ); -require( 'codemirror/addon/mode/overlay' ); -require( 'codemirror/addon/edit/closebrackets' ); -require( 'codemirror/addon/edit/closetag' ); -require( 'codemirror/addon/edit/continuelist' ); -require( 'codemirror/addon/edit/matchbrackets' ); -require( 'codemirror/addon/edit/matchtags' ); -require( 'codemirror/addon/edit/trailingspace' ); -require( 'codemirror/addon/dialog/dialog' ); -require( 'codemirror/addon/display/autorefresh' ); -require( 'codemirror/addon/display/fullscreen' ); -require( 'codemirror/addon/display/panel' ); -require( 'codemirror/addon/display/placeholder' ); -require( 'codemirror/addon/display/rulers' ); -require( 'codemirror/addon/fold/brace-fold' ); -require( 'codemirror/addon/fold/comment-fold' ); -require( 'codemirror/addon/fold/foldcode' ); -require( 'codemirror/addon/fold/foldgutter' ); -require( 'codemirror/addon/fold/indent-fold' ); -require( 'codemirror/addon/fold/markdown-fold' ); -require( 'codemirror/addon/merge/merge' ); -require( 'codemirror/addon/mode/loadmode' ); -require( 'codemirror/addon/mode/multiplex' ); -require( 'codemirror/addon/mode/simple' ); -require( 'codemirror/addon/runmode/runmode' ); -require( 'codemirror/addon/runmode/colorize' ); -require( 'codemirror/addon/runmode/runmode-standalone' ); -require( 'codemirror/addon/scroll/annotatescrollbar' ); -require( 'codemirror/addon/scroll/scrollpastend' ); -require( 'codemirror/addon/scroll/simplescrollbars' ); -require( 'codemirror/addon/search/search' ); -require( 'codemirror/addon/search/jump-to-line' ); -require( 'codemirror/addon/search/match-highlighter' ); -require( 'codemirror/addon/search/matchesonscrollbar' ); -require( 'codemirror/addon/search/searchcursor' ); -require( 'codemirror/addon/tern/tern' ); -require( 'codemirror/addon/tern/worker' ); -require( 'codemirror/addon/wrap/hardwrap' ); -require( 'codemirror/addon/selection/active-line' ); -require( 'codemirror/addon/selection/mark-selection' ); -require( 'codemirror/addon/selection/selection-pointer' ); +import 'codemirror/addon/comment/comment'; +import 'codemirror/addon/comment/continuecomment'; +import 'codemirror/addon/fold/xml-fold'; +import 'codemirror/addon/mode/overlay'; +import 'codemirror/addon/edit/closebrackets'; +import 'codemirror/addon/edit/closetag'; +import 'codemirror/addon/edit/continuelist'; +import 'codemirror/addon/edit/matchbrackets'; +import 'codemirror/addon/edit/matchtags'; +import 'codemirror/addon/edit/trailingspace'; +import 'codemirror/addon/dialog/dialog'; +import 'codemirror/addon/display/autorefresh'; +import 'codemirror/addon/display/fullscreen'; +import 'codemirror/addon/display/panel'; +import 'codemirror/addon/display/placeholder'; +import 'codemirror/addon/display/rulers'; +import 'codemirror/addon/fold/brace-fold'; +import 'codemirror/addon/fold/comment-fold'; +import 'codemirror/addon/fold/foldcode'; +import 'codemirror/addon/fold/foldgutter'; +import 'codemirror/addon/fold/indent-fold'; +import 'codemirror/addon/fold/markdown-fold'; +import 'codemirror/addon/merge/merge'; +import 'codemirror/addon/mode/loadmode'; +import 'codemirror/addon/mode/multiplex'; +import 'codemirror/addon/mode/simple'; +import 'codemirror/addon/runmode/runmode'; +import 'codemirror/addon/runmode/colorize'; +import 'codemirror/addon/runmode/runmode-standalone'; +import 'codemirror/addon/scroll/annotatescrollbar'; +import 'codemirror/addon/scroll/scrollpastend'; +import 'codemirror/addon/scroll/simplescrollbars'; +import 'codemirror/addon/search/search'; +import 'codemirror/addon/search/jump-to-line'; +import 'codemirror/addon/search/match-highlighter'; +import 'codemirror/addon/search/matchesonscrollbar'; +import 'codemirror/addon/search/searchcursor'; +import 'codemirror/addon/tern/tern'; +import 'codemirror/addon/tern/worker'; +import 'codemirror/addon/wrap/hardwrap'; +import 'codemirror/addon/selection/active-line'; +import 'codemirror/addon/selection/mark-selection'; +import 'codemirror/addon/selection/selection-pointer'; // Modes -require( 'codemirror/mode/meta' ); -require( 'codemirror/mode/clike/clike' ); -require( 'codemirror/mode/css/css' ); -require( 'codemirror/mode/diff/diff' ); -require( 'codemirror/mode/htmlmixed/htmlmixed' ); -require( 'codemirror/mode/http/http' ); -require( 'codemirror/mode/javascript/javascript' ); -require( 'codemirror/mode/jsx/jsx' ); -require( 'codemirror/mode/markdown/markdown' ); -require( 'codemirror/mode/gfm/gfm' ); -require( 'codemirror/mode/nginx/nginx' ); -require( 'codemirror/mode/php/php' ); -require( 'codemirror/mode/sass/sass' ); -require( 'codemirror/mode/shell/shell' ); -require( 'codemirror/mode/sql/sql' ); -require( 'codemirror/mode/xml/xml' ); -require( 'codemirror/mode/yaml/yaml' ); +import 'codemirror/mode/meta'; +import 'codemirror/mode/clike/clike'; +import 'codemirror/mode/css/css'; +import 'codemirror/mode/diff/diff'; +import 'codemirror/mode/htmlmixed/htmlmixed'; +import 'codemirror/mode/http/http'; +import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/jsx/jsx'; +import 'codemirror/mode/markdown/markdown'; +import 'codemirror/mode/gfm/gfm'; +import 'codemirror/mode/nginx/nginx'; +import 'codemirror/mode/php/php'; +import 'codemirror/mode/sass/sass'; +import 'codemirror/mode/shell/shell'; +import 'codemirror/mode/sql/sql'; +import 'codemirror/mode/xml/xml'; +import 'codemirror/mode/yaml/yaml'; /** * Please note that the codemirror-standalone "runmode" addon is setting `window.CodeMirror` diff --git a/tools/webpack/codemirror.config.js b/tools/webpack/codemirror.config.js index aac048dccc1ef..b6e99dd289daf 100644 --- a/tools/webpack/codemirror.config.js +++ b/tools/webpack/codemirror.config.js @@ -6,32 +6,36 @@ const codemirrorBanner = require( './codemirror-banner' ); module.exports = ( env = { buildTarget: 'src/' } ) => { const buildTarget = env.buildTarget || 'src/'; + const outputPath = path.resolve( __dirname, '../../', buildTarget, 'wp-includes/js/codemirror' ); - return { + const optimization = { + minimize: true, + minimizer: [ + new TerserPlugin( { + terserOptions: { + format: { + comments: /^!/, + }, + }, + extractComments: false, + } ), + ], + }; + + const codemirrorConfig = { target: 'browserslist', mode: 'production', - entry: './tools/vendors/codemirror-entry.js', - output: { - path: path.resolve( __dirname, '../../', buildTarget, 'wp-includes/js/codemirror' ), - filename: 'codemirror.min.js', + entry: { + 'codemirror.min': './tools/vendors/codemirror-entry.js', }, - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin( { - terserOptions: { - format: { - comments: /^!/, - }, - }, - extractComments: false, - } ), - ], + output: { + path: outputPath, + filename: '[name].js', }, + optimization, externals: { 'csslint': 'window.CSSLint', 'htmlhint': 'window.HTMLHint', - 'jshint': 'window.JSHINT', 'jsonlint': 'window.jsonlint', }, plugins: [ @@ -42,4 +46,25 @@ module.exports = ( env = { buildTarget: 'src/' } ) => { } ), ], }; + + const espreeConfig = { + target: 'browserslist', + mode: 'production', + entry: { + 'espree.min': 'espree', + }, + output: { + path: outputPath, + filename: '[name].js', + library: { + type: 'module', + }, + }, + experiments: { + outputModule: true, + }, + optimization, + }; + + return [ codemirrorConfig, espreeConfig ]; }; From 1479dc59255aa4e99651039fa8d79b9d4951b6bc Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 5 Feb 2026 16:03:04 -0700 Subject: [PATCH 022/147] 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 2c842f101aa9f22f70e524e568e032a99b8d6c58 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 5 Feb 2026 16:03:52 -0700 Subject: [PATCH 023/147] 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 024/147] feat: adds ai client --- phpunit.xml.dist | 1 + ...wp-ai-client-ability-function-resolver.php | 181 ++ .../class-wp-ai-client-discovery-strategy.php | 90 + .../class-wp-ai-client-event-dispatcher.php | 82 + .../class-wp-ai-client-http-client.php | 229 ++ .../class-wp-ai-client-prompt-builder.php | 370 +++ .../class-wp-ai-client-psr17-factory.php | 115 + .../class-wp-ai-client-psr7-request.php | 384 +++ .../class-wp-ai-client-psr7-response.php | 292 ++ .../class-wp-ai-client-psr7-stream.php | 243 ++ .../ai-client/class-wp-ai-client-psr7-uri.php | 389 +++ src/wp-includes/php-ai-client/autoload.php | 4 +- src/wp-settings.php | 21 + .../includes/wp-ai-client-mock-event.php | 17 + ...wp-ai-client-mock-model-creation-trait.php | 445 +++ .../wpAiClientAbilityFunctionResolver.php | 757 ++++++ .../ai-client/wpAiClientEventDispatcher.php | 55 + .../ai-client/wpAiClientPromptBuilder.php | 2406 +++++++++++++++++ 18 files changed, 6079 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-http-client.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php create mode 100644 src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php create mode 100644 tests/phpunit/includes/wp-ai-client-mock-event.php create mode 100644 tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4b6c149867c7d..2ba1cf60023df 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,6 +48,7 @@ src/wp-includes/PHPMailer src/wp-includes/Requests src/wp-includes/php-ai-client + src/wp-includes/ai-client src/wp-includes/SimplePie src/wp-includes/sodium_compat src/wp-includes/Text diff --git a/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php new file mode 100644 index 0000000000000..474314aab498a --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php @@ -0,0 +1,181 @@ +getName(); + if ( null === $name ) { + return false; + } + + return str_starts_with( $name, self::ABILITY_PREFIX ); + } + + /** + * Executes a WordPress ability from a function call. + * + * @since 6.8.0 + * + * @param FunctionCall $call The function call to execute. + * @return FunctionResponse The response from executing the ability. + */ + public static function execute_ability( FunctionCall $call ): FunctionResponse { + $function_name = $call->getName() ?? 'unknown'; + $function_id = $call->getId() ?? 'unknown'; + + if ( ! self::is_ability_call( $call ) ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => 'Not an ability function call', + 'code' => 'invalid_ability_call', + ) + ); + } + + $ability_name = self::function_name_to_ability_name( $function_name ); + $ability = wp_get_ability( $ability_name ); + + if ( ! $ability instanceof WP_Ability ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => sprintf( 'Ability "%s" not found', $ability_name ), + 'code' => 'ability_not_found', + ) + ); + } + + $args = $call->getArgs(); + $result = $ability->execute( ! empty( $args ) ? $args : null ); + + if ( is_wp_error( $result ) ) { + return new FunctionResponse( + $function_id, + $function_name, + array( + 'error' => $result->get_error_message(), + 'code' => $result->get_error_code(), + 'data' => $result->get_error_data(), + ) + ); + } + + return new FunctionResponse( + $function_id, + $function_name, + $result + ); + } + + /** + * Checks if a message contains any ability function calls. + * + * @since 6.8.0 + * + * @param Message $message The message to check. + * @return bool True if the message contains ability calls, false otherwise. + */ + public static function has_ability_calls( Message $message ): bool { + foreach ( $message->getParts() as $part ) { + if ( $part->getType()->isFunctionCall() ) { + $function_call = $part->getFunctionCall(); + if ( $function_call instanceof FunctionCall && self::is_ability_call( $function_call ) ) { + return true; + } + } + } + + return false; + } + + /** + * Executes all ability function calls in a message. + * + * @since 6.8.0 + * + * @param Message $message The message containing function calls. + * @return Message A new message with function responses. + */ + public static function execute_abilities( Message $message ): Message { + $response_parts = array(); + + foreach ( $message->getParts() as $part ) { + if ( $part->getType()->isFunctionCall() ) { + $function_call = $part->getFunctionCall(); + if ( $function_call instanceof FunctionCall ) { + $function_response = self::execute_ability( $function_call ); + $response_parts[] = new MessagePart( $function_response ); + } + } + } + + return new UserMessage( $response_parts ); + } + + /** + * Converts an ability name to a function name. + * + * Transforms "tec/create_event" to "wpab__tec__create_event". + * + * @since 6.8.0 + * + * @param string $ability_name The ability name to convert. + * @return string The function name. + */ + public static function ability_name_to_function_name( string $ability_name ): string { + return self::ABILITY_PREFIX . str_replace( '/', '__', $ability_name ); + } + + /** + * Converts a function name to an ability name. + * + * Transforms "wpab__tec__create_event" to "tec/create_event". + * + * @since 6.8.0 + * + * @param string $function_name The function name to convert. + * @return string The ability name. + */ + private static function function_name_to_ability_name( string $function_name ): string { + $without_prefix = substr( $function_name, strlen( self::ABILITY_PREFIX ) ); + + return str_replace( '__', '/', $without_prefix ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php new file mode 100644 index 0000000000000..4314609c3a7db --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php @@ -0,0 +1,90 @@ +> List of candidates. + */ + public static function getCandidates( $type ) { + if ( ClientInterface::class === $type ) { + return array( + array( + 'class' => static function () { + return self::create_wordpress_client(); + }, + ), + ); + } + + $psr17_factories = array( + 'Psr\Http\Message\RequestFactoryInterface', + 'Psr\Http\Message\ResponseFactoryInterface', + 'Psr\Http\Message\ServerRequestFactoryInterface', + 'Psr\Http\Message\StreamFactoryInterface', + 'Psr\Http\Message\UploadedFileFactoryInterface', + 'Psr\Http\Message\UriFactoryInterface', + ); + + if ( in_array( $type, $psr17_factories, true ) ) { + return array( + array( + 'class' => WP_AI_Client_PSR17_Factory::class, + ), + ); + } + + return array(); + } + + /** + * Creates an instance of the WordPress HTTP client. + * + * @since 6.8.0 + * + * @return WP_AI_Client_HTTP_Client + */ + private static function create_wordpress_client() { + $psr17_factory = new WP_AI_Client_PSR17_Factory(); + return new WP_AI_Client_HTTP_Client( + $psr17_factory, + $psr17_factory + ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php new file mode 100644 index 0000000000000..bfe294ed1d92f --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php @@ -0,0 +1,82 @@ +get_hook_name_portion_for_event( $event ); + + /** + * Fires when an AI client event is dispatched. + * + * The dynamic portion of the hook name, `$event_name`, refers to the + * snake_case version of the event class name, without the `_event` suffix. + * + * For example, an event class named `BeforeGenerateResultEvent` will fire the + * `wp_ai_client_before_generate_result` action hook. + * + * In practice, the available action hook names are: + * + * - wp_ai_client_before_generate_result + * - wp_ai_client_after_generate_result + * + * @since 6.8.0 + * + * @param object $event The event object. + */ + do_action( "wp_ai_client_{$event_name}", $event ); + + return $event; + } + + /** + * Converts an event object class name to a WordPress action hook name portion. + * + * @since 6.8.0 + * + * @param object $event The event object. + * @return string The hook name portion derived from the event class name. + */ + private function get_hook_name_portion_for_event( object $event ): string { + $class_name = get_class( $event ); + $pos = strrpos( $class_name, '\\' ); + $short_name = false !== $pos ? substr( $class_name, $pos + 1 ) : $class_name; + + // Convert PascalCase to snake_case. + $snake_case = strtolower( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $short_name ) ); + + // Strip '_event' suffix if present. + if ( str_ends_with( $snake_case, '_event' ) ) { + $snake_case = (string) substr( $snake_case, 0, -6 ); + } + + return $snake_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client/class-wp-ai-client-http-client.php new file mode 100644 index 0000000000000..a49324f130a47 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-http-client.php @@ -0,0 +1,229 @@ +response_factory = $response_factory; + $this->stream_factory = $stream_factory; + } + + /** + * Sends a PSR-7 request and returns a PSR-7 response. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return ResponseInterface The PSR-7 response. + * + * @throws NetworkException If the WordPress HTTP request fails. + */ + public function sendRequest( RequestInterface $request ): ResponseInterface { + $args = $this->prepare_wp_args( $request ); + $url = (string) $request->getUri(); + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + $message = sprintf( + 'Network error occurred while sending %s request to %s: %s', + $request->getMethod(), + $url, + $response->get_error_message() + ); + + throw new NetworkException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return $this->create_psr_response( $response ); + } + + /** + * Sends a PSR-7 request with transport options and returns a PSR-7 response. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @param RequestOptions $options Transport options for the request. + * @return ResponseInterface The PSR-7 response. + * + * @throws NetworkException If the WordPress HTTP request fails. + */ + public function sendRequestWithOptions( RequestInterface $request, RequestOptions $options ): ResponseInterface { + $args = $this->prepare_wp_args( $request, $options ); + $url = (string) $request->getUri(); + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + $message = sprintf( + 'Network error occurred while sending request to %s: %s', + $url, + $response->get_error_message() + ); + + throw new NetworkException( + $message, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + $response->get_error_code() ? (int) $response->get_error_code() : 0 + ); + } + + return $this->create_psr_response( $response ); + } + + /** + * Prepares WordPress HTTP API arguments from a PSR-7 request. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @param RequestOptions|null $options Optional transport options for the request. + * @return array WordPress HTTP API arguments. + */ + private function prepare_wp_args( RequestInterface $request, ?RequestOptions $options = null ): array { + $args = array( + 'method' => $request->getMethod(), + 'headers' => $this->prepare_headers( $request ), + 'body' => $this->prepare_body( $request ), + 'httpversion' => $request->getProtocolVersion(), + 'blocking' => true, + ); + + if ( null !== $options ) { + if ( null !== $options->getTimeout() ) { + $args['timeout'] = $options->getTimeout(); + } + + if ( null !== $options->getMaxRedirects() ) { + $args['redirection'] = $options->getMaxRedirects(); + } + } + + return $args; + } + + /** + * Prepares headers for WordPress HTTP API. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return array Headers array for WordPress HTTP API. + */ + private function prepare_headers( RequestInterface $request ): array { + $headers = array(); + + foreach ( $request->getHeaders() as $name => $values ) { + if ( strpos( $name, 'X-Stream' ) === 0 ) { + continue; + } + + $headers[ (string) $name ] = implode( ', ', $values ); + } + + return $headers; + } + + /** + * Prepares request body for WordPress HTTP API. + * + * @since 6.8.0 + * + * @param RequestInterface $request The PSR-7 request. + * @return string|null The request body. + */ + private function prepare_body( RequestInterface $request ): ?string { + $body = $request->getBody(); + + if ( $body->getSize() === 0 ) { + return null; + } + + if ( $body->isSeekable() ) { + $body->rewind(); + } + + return (string) $body; + } + + /** + * Creates a PSR-7 response from a WordPress HTTP response. + * + * @since 6.8.0 + * + * @param array $wp_response WordPress HTTP API response array. + * @return ResponseInterface PSR-7 response. + */ + private function create_psr_response( array $wp_response ): ResponseInterface { + $status_code = wp_remote_retrieve_response_code( $wp_response ); + $reason_phrase = wp_remote_retrieve_response_message( $wp_response ); + $headers = wp_remote_retrieve_headers( $wp_response ); + $body = wp_remote_retrieve_body( $wp_response ); + + $response = $this->response_factory->createResponse( (int) $status_code, $reason_phrase ); + + if ( $headers instanceof WP_HTTP_Requests_Response ) { + $headers = $headers->get_headers(); + } + + if ( is_array( $headers ) || $headers instanceof Traversable ) { + foreach ( $headers as $name => $value ) { + $response = $response->withHeader( $name, $value ); + } + } + + if ( ! empty( $body ) ) { + $stream = $this->stream_factory->createStream( $body ); + $response = $response->withBody( $stream ); + } + + return $response; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php new file mode 100644 index 0000000000000..e34e15e11936f --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -0,0 +1,370 @@ + $schema) Sets the output schema. + * @method self as_output_modalities(ModalityEnum ...$modalities) Sets the output modalities. + * @method self as_output_file_type(FileTypeEnum $fileType) Sets the output file type. + * @method self as_json_response(?array $schema = null) Configures the prompt for JSON response output. + * @method bool|WP_Error is_supported(?CapabilityEnum $capability = null) Checks if the prompt is supported for the given capability. + * @method bool is_supported_for_text_generation() Checks if the prompt is supported for text generation. + * @method bool is_supported_for_image_generation() Checks if the prompt is supported for image generation. + * @method bool is_supported_for_text_to_speech_conversion() Checks if the prompt is supported for text to speech conversion. + * @method bool is_supported_for_video_generation() Checks if the prompt is supported for video generation. + * @method bool is_supported_for_speech_generation() Checks if the prompt is supported for speech generation. + * @method bool is_supported_for_music_generation() Checks if the prompt is supported for music generation. + * @method bool is_supported_for_embedding_generation() Checks if the prompt is supported for embedding generation. + * @method GenerativeAiResult|WP_Error generate_result(?CapabilityEnum $capability = null) Generates a result from the prompt. + * @method GenerativeAiResult|WP_Error generate_text_result() Generates a text result from the prompt. + * @method GenerativeAiResult|WP_Error generate_image_result() Generates an image result from the prompt. + * @method GenerativeAiResult|WP_Error generate_speech_result() Generates a speech result from the prompt. + * @method GenerativeAiResult|WP_Error convert_text_to_speech_result() Converts text to speech and returns the result. + * @method string|WP_Error generate_text() Generates text from the prompt. + * @method list|WP_Error generate_texts(?int $candidateCount = null) Generates multiple text candidates from the prompt. + * @method File|WP_Error generate_image() Generates an image from the prompt. + * @method list|WP_Error generate_images(?int $candidateCount = null) Generates multiple images from the prompt. + * @method File|WP_Error convert_text_to_speech() Converts text to speech. + * @method list|WP_Error convert_text_to_speeches(?int $candidateCount = null) Converts text to multiple speech outputs. + * @method File|WP_Error generate_speech() Generates speech from the prompt. + * @method list|WP_Error generate_speeches(?int $candidateCount = null) Generates multiple speech outputs from the prompt. + */ +class WP_AI_Client_Prompt_Builder { + + /** + * Wrapped prompt builder instance from the PHP AI Client SDK. + * + * @since 6.8.0 + * @var PromptBuilder + */ + private PromptBuilder $builder; + + /** + * WordPress error instance, if any error occurred during method calls. + * + * @since 6.8.0 + * @var WP_Error|null + */ + private ?WP_Error $error = null; + + /** + * List of methods that terminate the fluent interface and return a result. + * + * Structured as a map for faster lookups. + * + * @since 6.8.0 + * @var array + */ + private static array $terminate_methods = array( + 'generate_result' => true, + 'generate_text_result' => true, + 'generate_image_result' => true, + 'generate_speech_result' => true, + 'convert_text_to_speech_result' => true, + 'generate_text' => true, + 'generate_texts' => true, + 'generate_image' => true, + 'generate_images' => true, + 'convert_text_to_speech' => true, + 'convert_text_to_speeches' => true, + 'generate_speech' => true, + 'generate_speeches' => true, + ); + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param mixed $prompt Optional initial prompt content. + */ + public function __construct( ProviderRegistry $registry, $prompt = null ) { + $this->builder = new PromptBuilder( $registry, $prompt ); + + /** + * Filters the default request timeout in seconds for AI Client HTTP requests. + * + * @since 6.8.0 + * + * @param int $default_timeout The default timeout in seconds. + */ + $default_timeout = (int) apply_filters( 'wp_ai_client_default_request_timeout', 30 ); + + $this->builder->usingRequestOptions( + RequestOptions::fromArray( + array( + RequestOptions::KEY_TIMEOUT => $default_timeout, + ) + ) + ); + } + + /** + * Registers WordPress abilities as function declarations for the AI model. + * + * Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix + * naming convention and passes them to the underlying prompt builder. + * + * @since 6.8.0 + * + * @param WP_Ability|string ...$abilities The abilities to register, either as WP_Ability objects or ability name strings. + * @return self The current instance for method chaining. + */ + public function using_abilities( ...$abilities ): self { + $declarations = array(); + + foreach ( $abilities as $ability ) { + if ( is_string( $ability ) ) { + $ability = wp_get_ability( $ability ); + } + + if ( ! $ability instanceof WP_Ability ) { + continue; + } + + $function_name = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $ability->get_name() ); + $input_schema = $ability->get_input_schema(); + + $declarations[] = new FunctionDeclaration( + $function_name, + $ability->get_description(), + ! empty( $input_schema ) ? $input_schema : null + ); + } + + if ( ! empty( $declarations ) ) { + return $this->using_function_declarations( ...$declarations ); + } + + return $this; + } + + /** + * Magic method to proxy snake_case method calls to their PHP AI Client camelCase counterparts. + * + * This allows WordPress developers to use snake_case naming conventions. It catches + * any exceptions thrown, stores them, and returns a WP_Error when a terminate method + * is called. + * + * @since 6.8.0 + * + * @param string $name The method name in snake_case. + * @param array $arguments The method arguments. + * @return mixed The result of the method call. + */ + public function __call( string $name, array $arguments ) { + /* + * If an error occurred in a previous method call, either return the error for terminate methods, + * or return the same instance for other methods to maintain the fluent interface. + */ + if ( null !== $this->error ) { + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + + // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. + if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + /** + * Filters whether to prevent the prompt from being executed. + * + * @since 6.8.0 + * + * @param bool $prevent Whether to prevent the prompt. Default false. + * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). + */ + $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); + + if ( $prevent ) { + // For is_supported* methods, return false. + if ( $this->is_support_check_method( $name ) ) { + return false; + } + + // For generate_* and convert_text_to_speech* methods, create a WP_Error. + $this->error = new WP_Error( + 'prompt_prevented', + 'Prompt execution was prevented by a filter.', + array( + 'exception_class' => 'WP_AI_Client_Prompt_Prevented', + ) + ); + + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + try { + $callable = $this->get_builder_callable( $name ); + $result = $callable( ...$arguments ); + + // If the result is a PromptBuilder, return the current instance to allow method chaining. + if ( $result instanceof PromptBuilder ) { + return $this; + } + + return $result; + } catch ( Exception $e ) { + $this->error = new WP_Error( + 'prompt_builder_error', + $e->getMessage(), + array( + 'exception_class' => get_class( $e ), + ) + ); + + if ( self::is_terminating_method( $name ) ) { + return $this->error; + } + return $this; + } + } + + /** + * Checks if a method name is a support check method (is_supported*). + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a support check method, false otherwise. + */ + protected function is_support_check_method( string $name ): bool { + return str_starts_with( $name, 'is_supported' ); + } + + /** + * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a generating method, false otherwise. + */ + protected function is_generating_method( string $name ): bool { + return str_starts_with( $name, 'generate_' ) + || str_starts_with( $name, 'convert_text_to_speech' ); + } + + /** + * Checks if a method is a terminating method. + * + * @since 6.8.0 + * + * @param string $name The method name. + * @return bool True if the method is a terminating method, false otherwise. + */ + private static function is_terminating_method( string $name ): bool { + return isset( self::$terminate_methods[ $name ] ); + } + + /** + * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. + * + * @since 6.8.0 + * + * @param string $name The method name in snake_case. + * @return callable The callable for the specified method. + * + * @throws BadMethodCallException If the method does not exist. + */ + protected function get_builder_callable( string $name ): callable { + $camel_case_name = $this->snake_to_camel_case( $name ); + + if ( ! is_callable( array( $this->builder, $camel_case_name ) ) ) { + throw new BadMethodCallException( + sprintf( + 'Method %s does not exist on %s', + $name, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + get_class( $this->builder ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + ) + ); + } + + return array( $this->builder, $camel_case_name ); + } + + /** + * Converts snake_case to camelCase. + * + * @since 6.8.0 + * + * @param string $snake_case The snake_case string. + * @return string The camelCase string. + */ + private function snake_to_camel_case( string $snake_case ): string { + $parts = explode( '_', $snake_case ); + + $camel_case = $parts[0]; + $parts_count = count( $parts ); + for ( $i = 1; $i < $parts_count; $i++ ) { + $camel_case .= ucfirst( $parts[ $i ] ); + } + + return $camel_case; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php new file mode 100644 index 0000000000000..c9a8f75b9e934 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php @@ -0,0 +1,115 @@ +}> + */ + private $headers = array(); + + /** + * Request body. + * + * @since 6.8.0 + * @var StreamInterface + */ + private $body; + + /** + * Explicit request target, if set. + * + * @since 6.8.0 + * @var string|null + */ + private $request_target; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param string $method HTTP method. + * @param string|UriInterface $uri Request URI. + */ + public function __construct( string $method, $uri ) { + $this->method = $method; + $this->uri = is_string( $uri ) ? new WP_AI_Client_PSR7_Uri( $uri ) : $uri; + $this->body = new WP_AI_Client_PSR7_Stream(); + + $host = $this->uri->getHost(); + if ( '' !== $host && ! $this->hasHeader( 'Host' ) ) { + $this->set_header_internal( 'Host', $host ); + } + } + + /** + * Retrieves the HTTP protocol version. + * + * @since 6.8.0 + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion(): string { + return $this->protocol_version; + } + + /** + * Returns an instance with the specified HTTP protocol version. + * + * @since 6.8.0 + * + * @param string $version HTTP protocol version. + * @return static + */ + public function withProtocolVersion( string $version ): self { + $new = clone $this; + $new->protocol_version = $version; + + return $new; + } + + /** + * Retrieves all message header values. + * + * @since 6.8.0 + * + * @return string[][] Associative array of headers. + */ + public function getHeaders(): array { + $result = array(); + + foreach ( $this->headers as $entry ) { + $result[ $entry['name'] ] = $entry['values']; + } + + return $result; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return bool + */ + public function hasHeader( string $name ): bool { + return isset( $this->headers[ strtolower( $name ) ] ); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string[] Header values. + */ + public function getHeader( string $name ): array { + $normalized = strtolower( $name ); + + if ( ! isset( $this->headers[ $normalized ] ) ) { + return array(); + } + + return $this->headers[ $normalized ]['values']; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string + */ + public function getHeaderLine( string $name ): string { + return implode( ', ', $this->getHeader( $name ) ); + } + + /** + * Returns an instance with the provided value replacing the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withHeader( string $name, $value ): self { + $new = clone $this; + $new->set_header_internal( $name, $value ); + + return $new; + } + + /** + * Returns an instance with the specified header appended with the given value. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withAddedHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $values = is_array( $value ) ? $value : array( $value ); + + if ( isset( $new->headers[ $normalized ] ) ) { + $new->headers[ $normalized ]['values'] = array_merge( + $new->headers[ $normalized ]['values'], + $values + ); + } else { + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => $values, + ); + } + + return $new; + } + + /** + * Returns an instance without the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader( string $name ): self { + $new = clone $this; + unset( $new->headers[ strtolower( $name ) ] ); + + return $new; + } + + /** + * Gets the body of the message. + * + * @since 6.8.0 + * + * @return StreamInterface + */ + public function getBody(): StreamInterface { + return $this->body; + } + + /** + * Returns an instance with the specified message body. + * + * @since 6.8.0 + * + * @param StreamInterface $body Body. + * @return static + */ + public function withBody( StreamInterface $body ): self { + $new = clone $this; + $new->body = $body; + + return $new; + } + + /** + * Retrieves the message's request target. + * + * @since 6.8.0 + * + * @return string + */ + public function getRequestTarget(): string { + if ( null !== $this->request_target ) { + return $this->request_target; + } + + $target = $this->uri->getPath(); + + if ( '' === $target ) { + $target = '/'; + } + + $query = $this->uri->getQuery(); + + if ( '' !== $query ) { + $target .= '?' . $query; + } + + return $target; + } + + /** + * Returns an instance with the specific request-target. + * + * @since 6.8.0 + * + * @param string $requestTarget Request target. + * @return static + */ + public function withRequestTarget( string $requestTarget ): self { + $new = clone $this; + $new->request_target = $requestTarget; + + return $new; + } + + /** + * Retrieves the HTTP method of the request. + * + * @since 6.8.0 + * + * @return string + */ + public function getMethod(): string { + return $this->method; + } + + /** + * Returns an instance with the provided HTTP method. + * + * @since 6.8.0 + * + * @param string $method Case-sensitive method. + * @return static + */ + public function withMethod( string $method ): self { + $new = clone $this; + $new->method = $method; + + return $new; + } + + /** + * Retrieves the URI instance. + * + * @since 6.8.0 + * + * @return UriInterface + */ + public function getUri(): UriInterface { + return $this->uri; + } + + /** + * Returns an instance with the provided URI. + * + * @since 6.8.0 + * + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function withUri( UriInterface $uri, bool $preserveHost = false ): self { + $new = clone $this; + $new->uri = $uri; + + $host = $uri->getHost(); + + if ( ! $preserveHost ) { + if ( '' !== $host ) { + $new->set_header_internal( 'Host', $host ); + } + } elseif ( '' !== $host && ( ! $new->hasHeader( 'Host' ) || '' === $new->getHeaderLine( 'Host' ) ) ) { + $new->set_header_internal( 'Host', $host ); + } + + return $new; + } + + /** + * Sets a header internally (mutating, for use in constructor and clone methods). + * + * @since 6.8.0 + * + * @param string $name Header name. + * @param string|string[] $value Header value(s). + */ + private function set_header_internal( string $name, $value ): void { + $normalized = strtolower( $name ); + $this->headers[ $normalized ] = array( + 'name' => $name, + 'values' => is_array( $value ) ? $value : array( $value ), + ); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php new file mode 100644 index 0000000000000..fe84a7dc5dfd1 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php @@ -0,0 +1,292 @@ +}> + */ + private $headers = array(); + + /** + * Response body. + * + * @since 6.8.0 + * @var StreamInterface + */ + private $body; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param int $status_code HTTP status code. + * @param string $reason_phrase Reason phrase to associate with the status code. + */ + public function __construct( int $status_code = 200, string $reason_phrase = '' ) { + $this->status_code = $status_code; + $this->reason_phrase = $reason_phrase; + $this->body = new WP_AI_Client_PSR7_Stream(); + } + + /** + * Gets the response status code. + * + * @since 6.8.0 + * + * @return int Status code. + */ + public function getStatusCode(): int { + return $this->status_code; + } + + /** + * Returns an instance with the specified status code and reason phrase. + * + * @since 6.8.0 + * + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use. + * @return static + */ + public function withStatus( int $code, string $reasonPhrase = '' ): self { + $new = clone $this; + $new->status_code = $code; + $new->reason_phrase = $reasonPhrase; + + return $new; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * @since 6.8.0 + * + * @return string Reason phrase. + */ + public function getReasonPhrase(): string { + return $this->reason_phrase; + } + + /** + * Retrieves the HTTP protocol version. + * + * @since 6.8.0 + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion(): string { + return $this->protocol_version; + } + + /** + * Returns an instance with the specified HTTP protocol version. + * + * @since 6.8.0 + * + * @param string $version HTTP protocol version. + * @return static + */ + public function withProtocolVersion( string $version ): self { + $new = clone $this; + $new->protocol_version = $version; + + return $new; + } + + /** + * Retrieves all message header values. + * + * @since 6.8.0 + * + * @return string[][] Associative array of headers. + */ + public function getHeaders(): array { + $result = array(); + + foreach ( $this->headers as $entry ) { + $result[ $entry['name'] ] = $entry['values']; + } + + return $result; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return bool + */ + public function hasHeader( string $name ): bool { + return isset( $this->headers[ strtolower( $name ) ] ); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string[] Header values. + */ + public function getHeader( string $name ): array { + $normalized = strtolower( $name ); + + if ( ! isset( $this->headers[ $normalized ] ) ) { + return array(); + } + + return $this->headers[ $normalized ]['values']; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @return string + */ + public function getHeaderLine( string $name ): string { + return implode( ', ', $this->getHeader( $name ) ); + } + + /** + * Returns an instance with the provided value replacing the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => is_array( $value ) ? $value : array( $value ), + ); + + return $new; + } + + /** + * Returns an instance with the specified header appended with the given value. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + */ + public function withAddedHeader( string $name, $value ): self { + $new = clone $this; + $normalized = strtolower( $name ); + $values = is_array( $value ) ? $value : array( $value ); + + if ( isset( $new->headers[ $normalized ] ) ) { + $new->headers[ $normalized ]['values'] = array_merge( + $new->headers[ $normalized ]['values'], + $values + ); + } else { + $new->headers[ $normalized ] = array( + 'name' => $name, + 'values' => $values, + ); + } + + return $new; + } + + /** + * Returns an instance without the specified header. + * + * @since 6.8.0 + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader( string $name ): self { + $new = clone $this; + unset( $new->headers[ strtolower( $name ) ] ); + + return $new; + } + + /** + * Gets the body of the message. + * + * @since 6.8.0 + * + * @return StreamInterface + */ + public function getBody(): StreamInterface { + return $this->body; + } + + /** + * Returns an instance with the specified message body. + * + * @since 6.8.0 + * + * @param StreamInterface $body Body. + * @return static + */ + public function withBody( StreamInterface $body ): self { + $new = clone $this; + $new->body = $body; + + return $new; + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php new file mode 100644 index 0000000000000..273b04a8fb669 --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php @@ -0,0 +1,243 @@ +content = $content; + } + + /** + * Reads all data from the stream into a string. + * + * @since 6.8.0 + * + * @return string + */ + public function __toString(): string { + return $this->content; + } + + /** + * Closes the stream. No-op for string-backed streams. + * + * @since 6.8.0 + */ + public function close(): void { + // No-op. + } + + /** + * Separates any underlying resources from the stream. + * + * @since 6.8.0 + * + * @return resource|null Always null for string-backed streams. + */ + public function detach() { + return null; + } + + /** + * Gets the size of the stream. + * + * @since 6.8.0 + * + * @return int|null The size in bytes. + */ + public function getSize(): ?int { + return strlen( $this->content ); + } + + /** + * Returns the current position of the read/write pointer. + * + * @since 6.8.0 + * + * @return int Position of the pointer. + */ + public function tell(): int { + return $this->offset; + } + + /** + * Returns true if the stream is at the end. + * + * @since 6.8.0 + * + * @return bool + */ + public function eof(): bool { + return $this->offset >= strlen( $this->content ); + } + + /** + * Returns whether the stream is seekable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isSeekable(): bool { + return true; + } + + /** + * Seeks to a position in the stream. + * + * @since 6.8.0 + * + * @param int $offset Stream offset. + * @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END. + */ + public function seek( int $offset, int $whence = SEEK_SET ): void { + $length = strlen( $this->content ); + + switch ( $whence ) { + case SEEK_SET: + $this->offset = $offset; + break; + case SEEK_CUR: + $this->offset += $offset; + break; + case SEEK_END: + $this->offset = $length + $offset; + break; + } + + if ( $this->offset < 0 ) { + $this->offset = 0; + } + } + + /** + * Seeks to the beginning of the stream. + * + * @since 6.8.0 + */ + public function rewind(): void { + $this->offset = 0; + } + + /** + * Returns whether the stream is writable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isWritable(): bool { + return true; + } + + /** + * Writes data to the stream. + * + * @since 6.8.0 + * + * @param string $string The string to write. + * @return int Number of bytes written. + */ + public function write( string $string ): int { + $this->content .= $string; + $length = strlen( $string ); + $this->offset += $length; + + return $length; + } + + /** + * Returns whether the stream is readable. + * + * @since 6.8.0 + * + * @return bool Always true. + */ + public function isReadable(): bool { + return true; + } + + /** + * Reads data from the stream. + * + * @since 6.8.0 + * + * @param int $length Number of bytes to read. + * @return string Data read from the stream. + */ + public function read( int $length ): string { + $data = substr( $this->content, $this->offset, $length ); + $this->offset += strlen( $data ); + + return $data; + } + + /** + * Returns the remaining contents of the stream. + * + * @since 6.8.0 + * + * @return string + */ + public function getContents(): string { + $remaining = substr( $this->content, $this->offset ); + $this->offset = strlen( $this->content ); + + return $remaining; + } + + /** + * Gets stream metadata. + * + * @since 6.8.0 + * + * @param string|null $key Specific metadata to retrieve. + * @return array|mixed|null Returns null for specific keys, empty array otherwise. + */ + public function getMetadata( ?string $key = null ) { + if ( null !== $key ) { + return null; + } + + return array(); + } +} diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php new file mode 100644 index 0000000000000..8ea0cf4546b7a --- /dev/null +++ b/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php @@ -0,0 +1,389 @@ + + */ + private static $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + + /** + * URI scheme (e.g. "http", "https"). + * + * @since 6.8.0 + * @var string + */ + private $scheme = ''; + + /** + * URI user info (e.g. "user:password"). + * + * @since 6.8.0 + * @var string + */ + private $user_info = ''; + + /** + * URI host. + * + * @since 6.8.0 + * @var string + */ + private $host = ''; + + /** + * URI port. + * + * @since 6.8.0 + * @var int|null + */ + private $port; + + /** + * URI path. + * + * @since 6.8.0 + * @var string + */ + private $path = ''; + + /** + * URI query string. + * + * @since 6.8.0 + * @var string + */ + private $query = ''; + + /** + * URI fragment. + * + * @since 6.8.0 + * @var string + */ + private $fragment = ''; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param string $uri URI string to parse. + */ + public function __construct( string $uri = '' ) { + if ( '' !== $uri ) { + $parts = wp_parse_url( $uri ); + + if ( false !== $parts ) { + $this->scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; + $this->host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; + $this->port = isset( $parts['port'] ) ? (int) $parts['port'] : null; + $this->path = $parts['path'] ?? ''; + $this->query = $parts['query'] ?? ''; + + $this->fragment = $parts['fragment'] ?? ''; + + if ( isset( $parts['user'] ) ) { + $this->user_info = $parts['user']; + if ( isset( $parts['pass'] ) ) { + $this->user_info .= ':' . $parts['pass']; + } + } + } + } + } + + /** + * Retrieves the scheme component of the URI. + * + * @since 6.8.0 + * + * @return string The URI scheme. + */ + public function getScheme(): string { + return $this->scheme; + } + + /** + * Retrieves the authority component of the URI. + * + * @since 6.8.0 + * + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(): string { + if ( '' === $this->host ) { + return ''; + } + + $authority = $this->host; + + if ( '' !== $this->user_info ) { + $authority = $this->user_info . '@' . $authority; + } + + if ( null !== $this->port && ! $this->is_standard_port() ) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * Retrieves the user information component of the URI. + * + * @since 6.8.0 + * + * @return string The URI user information. + */ + public function getUserInfo(): string { + return $this->user_info; + } + + /** + * Retrieves the host component of the URI. + * + * @since 6.8.0 + * + * @return string The URI host. + */ + public function getHost(): string { + return $this->host; + } + + /** + * Retrieves the port component of the URI. + * + * @since 6.8.0 + * + * @return int|null The URI port, or null if standard or not set. + */ + public function getPort(): ?int { + if ( $this->is_standard_port() ) { + return null; + } + + return $this->port; + } + + /** + * Retrieves the path component of the URI. + * + * @since 6.8.0 + * + * @return string The URI path. + */ + public function getPath(): string { + return $this->path; + } + + /** + * Retrieves the query string of the URI. + * + * @since 6.8.0 + * + * @return string The URI query string. + */ + public function getQuery(): string { + return $this->query; + } + + /** + * Retrieves the fragment component of the URI. + * + * @since 6.8.0 + * + * @return string The URI fragment. + */ + public function getFragment(): string { + return $this->fragment; + } + + /** + * Returns an instance with the specified scheme. + * + * @since 6.8.0 + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + */ + public function withScheme( string $scheme ): UriInterface { + $new = clone $this; + $new->scheme = strtolower( $scheme ); + + return $new; + } + + /** + * Returns an instance with the specified user information. + * + * @since 6.8.0 + * + * @param string $user The user name to use for authority. + * @param string|null $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo( string $user, ?string $password = null ): UriInterface { + $new = clone $this; + $new->user_info = $user; + + if ( null !== $password && '' !== $password ) { + $new->user_info .= ':' . $password; + } + + return $new; + } + + /** + * Returns an instance with the specified host. + * + * @since 6.8.0 + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + */ + public function withHost( string $host ): UriInterface { + $new = clone $this; + $new->host = strtolower( $host ); + + return $new; + } + + /** + * Returns an instance with the specified port. + * + * @since 6.8.0 + * + * @param int|null $port The port to use with the new instance. + * @return static A new instance with the specified port. + */ + public function withPort( ?int $port ): UriInterface { + $new = clone $this; + $new->port = $port; + + return $new; + } + + /** + * Returns an instance with the specified path. + * + * @since 6.8.0 + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + */ + public function withPath( string $path ): UriInterface { + $new = clone $this; + $new->path = $path; + + return $new; + } + + /** + * Returns an instance with the specified query string. + * + * @since 6.8.0 + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + */ + public function withQuery( string $query ): UriInterface { + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * Returns an instance with the specified URI fragment. + * + * @since 6.8.0 + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment( string $fragment ): UriInterface { + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * Returns the string representation as a URI reference. + * + * @since 6.8.0 + * + * @return string + */ + public function __toString(): string { + $uri = ''; + $authority = $this->getAuthority(); + + if ( '' !== $this->scheme ) { + $uri .= $this->scheme . ':'; + } + + if ( '' !== $authority ) { + $uri .= '//' . $authority; + } + + $path = $this->path; + + if ( '' !== $authority && ( '' === $path || '/' !== $path[0] ) ) { + $path = '/' . $path; + } elseif ( '' === $authority && str_starts_with( $path, '//' ) ) { + $path = '/' . ltrim( $path, '/' ); + } + + $uri .= $path; + + if ( '' !== $this->query ) { + $uri .= '?' . $this->query; + } + + if ( '' !== $this->fragment ) { + $uri .= '#' . $this->fragment; + } + + return $uri; + } + + /** + * Checks whether the current port is the standard port for the scheme. + * + * @since 6.8.0 + * + * @return bool True if port is the standard port for the current scheme. + */ + private function is_standard_port(): bool { + if ( null === $this->port ) { + return false; + } + + return isset( self::$default_ports[ $this->scheme ] ) + && self::$default_ports[ $this->scheme ] === $this->port; + } +} diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php index 89548a78aa737..7cd81ed038277 100644 --- a/src/wp-includes/php-ai-client/autoload.php +++ b/src/wp-includes/php-ai-client/autoload.php @@ -17,7 +17,7 @@ static function ( $class_name ) { // Namespace prefix for the AI client. $client_prefix = 'WordPress\\AiClient\\'; - $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' ) + $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) // Namespace prefix for scoped dependencies. $scoped_prefix = 'WordPress\\AiClientDependencies\\'; @@ -27,7 +27,7 @@ static function ( $class_name ) { $psr_prefixes = array( 'Psr\\Http\\Client\\' => 16, 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 21, + 'Psr\\EventDispatcher\\' => 20, 'Psr\\SimpleCache\\' => 16, ); diff --git a/src/wp-settings.php b/src/wp-settings.php index f7dfd28fbcc93..23153988bee04 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -287,6 +287,27 @@ require ABSPATH . WPINC . '/class-wp-http-requests-response.php'; require ABSPATH . WPINC . '/class-wp-http-requests-hooks.php'; require ABSPATH . WPINC . '/php-ai-client/autoload.php'; + +// WP AI Client - PSR-7 implementations. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-stream.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php'; + +// WP AI Client - HTTP transport and infrastructure. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-http-client.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php'; + +// WP AI Client - Prompt builder. +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; + +// WP AI Client - Initialization. +WP_AI_Client_Discovery_Strategy::init(); +WordPress\AiClient\AiClient::setEventDispatcher( new WP_AI_Client_Event_Dispatcher() ); + require ABSPATH . WPINC . '/widgets.php'; require ABSPATH . WPINC . '/class-wp-widget.php'; require ABSPATH . WPINC . '/class-wp-widget-factory.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-event.php b/tests/phpunit/includes/wp-ai-client-mock-event.php new file mode 100644 index 0000000000000..5bef4912db7a7 --- /dev/null +++ b/tests/phpunit/includes/wp-ai-client-mock-event.php @@ -0,0 +1,17 @@ +create_test_text_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + + public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + yield $this->result; + } + }; + } + + /** + * Creates a mock image generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&ImageGenerationModelInterface The mock model. + */ + protected function create_mock_image_generation_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_image_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, ImageGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateImageResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock speech generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&SpeechGenerationModelInterface The mock model. + */ + protected function create_mock_speech_generation_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_speech_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, SpeechGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock text-to-speech conversion model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from conversion. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&TextToSpeechConversionModelInterface The mock model. + */ + protected function create_mock_text_to_speech_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_text_to_speech_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, TextToSpeechConversionModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function convertTextToSpeechResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + + /** + * Creates a mock text generation model that throws an exception. + * + * @param Exception $exception The exception to throw from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&TextGenerationModelInterface The mock model. + */ + protected function create_mock_text_generation_model_with_exception( + Exception $exception, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_text_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $exception ) implements ModelInterface, TextGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private Exception $exception; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + Exception $exception + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->exception = $exception; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateTextResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + throw $this->exception; + } + + public function streamGenerateTextResult( array $prompt ): Generator { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + throw $this->exception; + } + }; + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php new file mode 100644 index 0000000000000..fb5e2fefb9f29 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php @@ -0,0 +1,757 @@ + 'WP AI Client Tests', + 'description' => 'Test abilities for WP AI Client.', + ) + ); + + array_pop( $wp_current_filter ); + + // Simulate the abilities init action. + $wp_current_filter[] = 'wp_abilities_api_init'; + + // Register test abilities. + wp_register_ability( + 'wpaiclienttests/simple', + array( + 'label' => 'Simple Test Ability', + 'description' => 'A simple test ability with no parameters.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return array( 'success' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/with-params', + array( + 'label' => 'Test Ability With Parameters', + 'description' => 'A test ability that accepts parameters.', + 'category' => 'wpaiclienttests', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'description' => 'The title parameter.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( array $input ) { + return array( + 'success' => true, + 'title' => $input['title'], + ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/returns-error', + array( + 'label' => 'Test Ability That Returns Error', + 'description' => 'A test ability that returns a WP_Error.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return new WP_Error( 'test_error', 'This is a test error message.' ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + wp_register_ability( + 'wpaiclienttests/hyphen-test', + array( + 'label' => 'Test Ability With Hyphens', + 'description' => 'A test ability to verify hyphenated names.', + 'category' => 'wpaiclienttests', + 'execute_callback' => static function () { + return array( 'hyphenated' => true ); + }, + 'permission_callback' => static function () { + return true; + }, + ) + ); + + array_pop( $wp_current_filter ); + } + + /** + * Test that is_ability_call returns true for a valid ability call. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_true_for_valid_ability() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__create_event', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertTrue( $result ); + } + + /** + * Test that is_ability_call returns true for a nested namespace. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_true_for_nested_namespace() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__v1__create_event', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertTrue( $result ); + } + + /** + * Test that is_ability_call returns false for a non-ability call. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_for_non_ability() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that is_ability_call returns false when name is null. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_when_name_is_null() { + $call = new FunctionCall( + 'test-id', + null, + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that is_ability_call returns false for partial prefix. + * + * @ticket TBD + */ + public function test_is_ability_call_returns_false_for_partial_prefix() { + $call = new FunctionCall( + 'test-id', + 'wpab_single_underscore', + array() + ); + + $result = WP_AI_Client_Ability_Function_Resolver::is_ability_call( $call ); + + $this->assertFalse( $result ); + } + + /** + * Test that execute_ability returns error for non-ability call. + * + * @ticket TBD + */ + public function test_execute_ability_returns_error_for_non_ability_call() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'regular_function', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertSame( 'Not an ability function call', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'invalid_ability_call', $data['code'] ); + } + + /** + * Test that execute_ability returns error when ability not found. + * + * @ticket TBD + */ + public function test_execute_ability_returns_error_when_ability_not_found() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__nonexistent__ability', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'not found', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'ability_not_found', $data['code'] ); + } + + /** + * Test that execute_ability handles missing id. + * + * @ticket TBD + */ + public function test_execute_ability_handles_missing_id() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + null, + 'wpab__nonexistent__ability', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'unknown', $response->getId() ); + } + + /** + * Test that has_ability_calls returns true when ability call is present. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_true_when_present() { + $call = new FunctionCall( + 'test-id', + 'wpab__tec__create_event', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Here is the result:' ), + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertTrue( $result ); + } + + /** + * Test that has_ability_calls returns false when ability call is not present. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_false_when_not_present() { + $call = new FunctionCall( + 'test-id', + 'regular_function', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Here is the result:' ), + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that has_ability_calls returns false for text-only message. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_false_for_text_only() { + $message = new UserMessage( + array( + new MessagePart( 'Just some text' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that has_ability_calls returns true with mixed content. + * + * @ticket TBD + */ + public function test_has_ability_calls_returns_true_with_mixed_content() { + $regular_call = new FunctionCall( + 'regular-id', + 'regular_function', + array() + ); + + $ability_call = new FunctionCall( + 'ability-id', + 'wpab__tec__create_event', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Some text' ), + new MessagePart( $regular_call ), + new MessagePart( $ability_call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertTrue( $result ); + } + + /** + * Test that has_ability_calls handles empty message. + * + * @ticket TBD + */ + public function test_has_ability_calls_with_empty_message() { + $message = new ModelMessage( array() ); + + $result = WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $message ); + + $this->assertFalse( $result ); + } + + /** + * Test that execute_abilities handles empty message. + * + * @ticket TBD + */ + public function test_execute_abilities_with_empty_message() { + $message = new ModelMessage( array() ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $this->assertCount( 0, $result->getParts() ); + } + + /** + * Test that execute_abilities handles errors gracefully. + * + * @ticket TBD + */ + public function test_execute_abilities_handles_errors_gracefully() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'error', $data ); + } + + /** + * Test that execute_abilities returns a UserMessage. + * + * @ticket TBD + */ + public function test_execute_abilities_returns_user_message() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + } + + /** + * Test that execute_abilities processes multiple calls. + * + * @ticket TBD + */ + public function test_execute_abilities_processes_multiple_calls() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call1 = new FunctionCall( + 'call-1', + 'wpab__nonexistent__ability1', + array() + ); + + $call2 = new FunctionCall( + 'call-2', + 'wpab__nonexistent__ability2', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call1 ), + new MessagePart( $call2 ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 2, $parts ); + } + + /** + * Test that execute_abilities only processes function calls. + * + * @ticket TBD + */ + public function test_execute_abilities_only_processes_function_calls() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $call = new FunctionCall( + 'test-id', + 'wpab__nonexistent__ability', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Some text' ), + new MessagePart( $call ), + new MessagePart( 'More text' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + // Only the function call should be processed. + $this->assertCount( 1, $parts ); + } + + /** + * Test ability_name_to_function_name with simple name. + * + * @ticket TBD + */ + public function test_ability_name_to_function_name_simple() { + $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/create_event' ); + + $this->assertSame( 'wpab__tec__create_event', $result ); + } + + /** + * Test ability_name_to_function_name with nested namespace. + * + * @ticket TBD + */ + public function test_ability_name_to_function_name_nested() { + $result = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( 'tec/v1/create_event' ); + + $this->assertSame( 'wpab__tec__v1__create_event', $result ); + } + + /** + * Test execute_ability with successful execution. + * + * @ticket TBD + */ + public function test_execute_ability_success() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__simple', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + } + + /** + * Test execute_ability with parameters. + * + * @ticket TBD + */ + public function test_execute_ability_with_parameters() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__with-params', + array( 'title' => 'Test Title' ) + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__with-params', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertSame( 'Test Title', $data['title'] ); + } + + /** + * Test execute_ability handles WP_Error. + * + * @ticket TBD + */ + public function test_execute_ability_handles_wp_error() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__returns-error', + array() + ); + + $response = WP_AI_Client_Ability_Function_Resolver::execute_ability( $call ); + + $this->assertInstanceOf( FunctionResponse::class, $response ); + $this->assertSame( 'test-id', $response->getId() ); + $this->assertSame( 'wpab__wpaiclienttests__returns-error', $response->getName() ); + $data = $response->getResponse(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'error', $data ); + $this->assertSame( 'This is a test error message.', $data['error'] ); + $this->assertArrayHasKey( 'code', $data ); + $this->assertSame( 'test_error', $data['code'] ); + } + + /** + * Test execute_abilities with successful execution. + * + * @ticket TBD + */ + public function test_execute_abilities_success() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + } + + /** + * Test execute_abilities with multiple successful executions. + * + * @ticket TBD + */ + public function test_execute_abilities_multiple_success() { + $call1 = new FunctionCall( + 'call-1', + 'wpab__wpaiclienttests__simple', + array() + ); + + $call2 = new FunctionCall( + 'call-2', + 'wpab__wpaiclienttests__hyphen-test', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( $call1 ), + new MessagePart( $call2 ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 2, $parts ); + + // Check first response. + $response1 = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response1 ); + $data1 = $response1->getResponse(); + $this->assertArrayHasKey( 'success', $data1 ); + $this->assertTrue( $data1['success'] ); + + // Check second response. + $response2 = $parts[1]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response2 ); + $data2 = $response2->getResponse(); + $this->assertArrayHasKey( 'hyphenated', $data2 ); + $this->assertTrue( $data2['hyphenated'] ); + } + + /** + * Test execute_abilities with mixed text and ability calls. + * + * @ticket TBD + */ + public function test_execute_abilities_with_mixed_content() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__simple', + array() + ); + + $message = new ModelMessage( + array( + new MessagePart( 'Starting execution' ), + new MessagePart( $call ), + new MessagePart( 'Execution complete' ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + // Only function calls should be processed. + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + } + + /** + * Test execute_abilities with ability that has parameters. + * + * @ticket TBD + */ + public function test_execute_abilities_with_parameters() { + $call = new FunctionCall( + 'test-id', + 'wpab__wpaiclienttests__with-params', + array( 'title' => 'Integration Test' ) + ); + + $message = new ModelMessage( + array( + new MessagePart( $call ), + ) + ); + + $result = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $message ); + + $this->assertInstanceOf( UserMessage::class, $result ); + $parts = $result->getParts(); + $this->assertCount( 1, $parts ); + + $response = $parts[0]->getFunctionResponse(); + $this->assertInstanceOf( FunctionResponse::class, $response ); + $data = $response->getResponse(); + $this->assertArrayHasKey( 'success', $data ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertSame( 'Integration Test', $data['title'] ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php new file mode 100644 index 0000000000000..6e7c7aac40953 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php @@ -0,0 +1,55 @@ +dispatch( $event ); + + $this->assertTrue( $hook_fired, 'The action hook should have been fired' ); + $this->assertSame( $event, $fired_event, 'The fired event should be the same as the dispatched event' ); + $this->assertSame( $event, $result, 'The dispatch method should return the same event' ); + } + + /** + * Test that dispatch returns event without listeners. + * + * @ticket TBD + */ + public function test_dispatch_returns_event_without_listeners() { + $dispatcher = new WP_AI_Client_Event_Dispatcher(); + $event = new stdClass(); + $event->test_value = 'original'; + + $result = $dispatcher->dispatch( $event ); + + $this->assertSame( $event, $result, 'The dispatch method should return the same object' ); + $this->assertSame( 'original', $result->test_value, 'The event object should remain unchanged' ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php new file mode 100644 index 0000000000000..b44417bae77b3 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -0,0 +1,2406 @@ +getProperty( 'builder' ); + $builder_property->setAccessible( true ); + + $wrapped_builder = $builder_property->getValue( $builder ); + + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $the_property = $reflection_class2->getProperty( $property ); + $the_property->setAccessible( true ); + + return $the_property->getValue( $wrapped_builder ); + } + + /** + * Gets the function declarations from the builder's model config. + * + * @param WP_AI_Client_Prompt_Builder $builder The builder to get declarations from. + * @return list|null The function declarations or null if not set. + */ + private function get_function_declarations( WP_AI_Client_Prompt_Builder $builder ): ?array { + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + return $config->getFunctionDeclarations(); + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + + $this->registry = $this->createMock( ProviderRegistry::class ); + } + + /** + * Test that WP_AI_Client_Prompt_Builder can be instantiated. + * + * @ticket TBD + */ + public function test_instantiation() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder ); + + // Verify the wrapped builder is a PromptBuilder instance. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $prompt_builder ); + + $this->assertInstanceOf( PromptBuilder::class, $wrapped_builder ); + } + + /** + * Test that WP_AI_Client_Prompt_Builder can be instantiated with initial prompt content. + * + * @ticket TBD + */ + public function test_instantiation_with_prompt() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Initial prompt text' ); + + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $prompt_builder ); + } + + /** + * Test that the constructor sets the default request timeout. + * + * @ticket TBD + */ + public function test_constructor_sets_default_request_timeout() { + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertEquals( 30, $request_options->getTimeout() ); + } + + /** + * Test that the constructor allows overriding the default request timeout. + * + * @ticket TBD + */ + public function test_constructor_allows_overriding_request_timeout() { + add_filter( + 'wp_ai_client_default_request_timeout', + static function () { + return 45; + } + ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + + /** @var RequestOptions $request_options */ + $request_options = $this->get_wrapped_prompt_builder_property_value( $builder, 'requestOptions' ); + + $this->assertInstanceOf( RequestOptions::class, $request_options ); + $this->assertEquals( 45, $request_options->getTimeout() ); + } + + /** + * Test method chaining with fluent methods. + * + * @ticket TBD + */ + public function test_method_chaining_returns_decorator() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder->with_text( 'Test text' ); + $this->assertSame( $prompt_builder, $result, 'with_text should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + + $result = $prompt_builder->using_system_instruction( 'System instruction' ); + $this->assertSame( $prompt_builder, $result, 'using_system_instruction should return the decorator instance' ); + + $result = $prompt_builder->using_max_tokens( 100 ); + $this->assertSame( $prompt_builder, $result, 'using_max_tokens should return the decorator instance' ); + + $result = $prompt_builder->using_temperature( 0.7 ); + $this->assertSame( $prompt_builder, $result, 'using_temperature should return the decorator instance' ); + + $result = $prompt_builder->using_top_p( 0.9 ); + $this->assertSame( $prompt_builder, $result, 'using_top_p should return the decorator instance' ); + + $result = $prompt_builder->using_top_k( 50 ); + $this->assertSame( $prompt_builder, $result, 'using_top_k should return the decorator instance' ); + + $result = $prompt_builder->using_presence_penalty( 0.5 ); + $this->assertSame( $prompt_builder, $result, 'using_presence_penalty should return the decorator instance' ); + + $result = $prompt_builder->using_frequency_penalty( 0.5 ); + $this->assertSame( $prompt_builder, $result, 'using_frequency_penalty should return the decorator instance' ); + + $result = $prompt_builder->as_output_mime_type( 'application/json' ); + $this->assertSame( $prompt_builder, $result, 'as_output_mime_type should return the decorator instance' ); + } + + /** + * Test complex method chaining scenario. + * + * @ticket TBD + */ + public function test_complex_method_chaining() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder + ->with_text( 'Test prompt' ) + ->using_system_instruction( 'You are a helpful assistant' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.7 ) + ->using_top_p( 0.9 ); + + $this->assertSame( $prompt_builder, $result, 'Chained methods should return the same decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Test that boolean-returning methods do not return the decorator. + * + * @ticket TBD + */ + public function test_boolean_methods_return_boolean() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry, 'Test text' ); + + $result = $prompt_builder->is_supported_for_text_generation(); + $this->assertIsBool( $result, 'is_supported_for_text_generation should return a boolean' ); + $this->assertNotSame( $prompt_builder, $result, 'is_supported_for_text_generation should not return the decorator' ); + } + + /** + * Test snake_case to camelCase conversion. + * + * @ticket TBD + */ + public function test_snake_case_to_camel_case_conversion() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $test_cases = array( + 'with_text' => 'withText', + 'using_system_instruction' => 'usingSystemInstruction', + 'using_max_tokens' => 'usingMaxTokens', + 'as_output_mime_type' => 'asOutputMimeType', + 'using_model_config' => 'usingModelConfig', + 'with_message_parts' => 'withMessageParts', + 'using_stop_sequences' => 'usingStopSequences', + 'using_candidate_count' => 'usingCandidateCount', + 'using_function_declarations' => 'usingFunctionDeclarations', + ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $conversion_method = $reflection_class->getMethod( 'snake_to_camel_case' ); + $conversion_method->setAccessible( true ); + + foreach ( $test_cases as $snake_case => $expected_camel_case ) { + $actual_camel_case = $conversion_method->invoke( $prompt_builder, $snake_case ); + $this->assertSame( $expected_camel_case, $actual_camel_case, "Failed converting {$snake_case} to {$expected_camel_case}" ); + } + } + + /** + * Test that calling a non-existent method returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_invalid_method_returns_wp_error() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Invalid method call stores error but returns $this for chaining. + $result = $prompt_builder->non_existent_method(); + $this->assertSame( $prompt_builder, $result ); + + // Calling a terminate method should return the stored WP_Error. + $result = $prompt_builder->generate_text(); + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'non_existent_method does not exist', $result->get_error_message() ); + } + + /** + * Test that get_builder_callable returns a valid callable. + * + * @ticket TBD + */ + public function test_get_builder_callable() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $callable_method = $reflection_class->getMethod( 'get_builder_callable' ); + $callable_method->setAccessible( true ); + + $callable = $callable_method->invoke( $prompt_builder, 'with_text' ); + $this->assertTrue( is_callable( $callable ), 'get_builder_callable should return a valid callable' ); + + $this->assertIsArray( $callable ); + $this->assertCount( 2, $callable ); + $this->assertInstanceOf( PromptBuilder::class, $callable[0] ); + $this->assertSame( 'withText', $callable[1] ); + } + + /** + * Test that the wrapped builder is properly configured with the registry. + * + * @ticket TBD + */ + public function test_wrapped_builder_has_correct_registry() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $prompt_builder ); + + $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) ); + $registry_property = $wrapped_builder_reflection->getProperty( 'registry' ); + $registry_property->setAccessible( true ); + + $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' ); + } + + /** + * Test method chaining with with_history. + * + * @ticket TBD + */ + public function test_method_chaining_with_history() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $message1 = Message::fromArray( + array( + 'role' => 'user', + 'parts' => array( + array( + 'text' => 'Hello', + ), + ), + ) + ); + $message2 = Message::fromArray( + array( + 'role' => 'user', + 'parts' => array( + array( + 'text' => 'How are you?', + ), + ), + ) + ); + + $result = $prompt_builder->with_history( $message1, $message2 ); + $this->assertSame( $prompt_builder, $result, 'with_history should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Test method chaining with using_model_config. + * + * @ticket TBD + */ + public function test_method_chaining_with_model_config() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $config = new ModelConfig( array( 'maxTokens' => 100 ) ); + + $result = $prompt_builder->using_model_config( $config ); + $this->assertSame( $prompt_builder, $result, 'using_model_config should return the decorator instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + } + + /** + * Tests constructor with no prompt. + * + * @ticket TBD + */ + public function test_constructor_with_no_prompt() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + $this->assertEmpty( $messages ); + } + + /** + * Tests constructor with string prompt. + * + * @ticket TBD + */ + public function test_constructor_with_string_prompt() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Hello, world!' ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Hello, world!', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests constructor with MessagePart prompt. + * + * @ticket TBD + */ + public function test_constructor_with_message_part_prompt() { + $part = new MessagePart( 'Test message' ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $part ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Test message', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests constructor with Message prompt. + * + * @ticket TBD + */ + public function test_constructor_with_message_prompt() { + $message = new UserMessage( array( new MessagePart( 'User message' ) ) ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertSame( $message, $messages[0] ); + } + + /** + * Tests constructor with list of Messages. + * + * @ticket TBD + */ + public function test_constructor_with_messages_list() { + $messages = array( + new UserMessage( array( new MessagePart( 'First' ) ) ), + new ModelMessage( array( new MessagePart( 'Second' ) ) ), + new UserMessage( array( new MessagePart( 'Third' ) ) ), + ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $messages ); + + /** @var list $actual_messages */ + $actual_messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 3, $actual_messages ); + $this->assertSame( $messages, $actual_messages ); + } + + /** + * Tests constructor with MessageArrayShape. + * + * @ticket TBD + */ + public function test_constructor_with_message_array_shape() { + $message_array = array( + 'role' => 'user', + 'parts' => array( + array( + 'type' => 'text', + 'text' => 'Hello from array', + ), + ), + ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, $message_array ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $this->assertEquals( 'Hello from array', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests withText method. + * + * @ticket TBD + */ + public function test_with_text() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_text( 'Some text' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertEquals( 'Some text', $messages[0]->getParts()[0]->getText() ); + } + + /** + * Tests withText appends to existing user message. + * + * @ticket TBD + */ + public function test_with_text_appends_to_existing_user_message() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Initial text' ); + $builder->with_text( ' Additional text' ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 2, $parts ); + $this->assertEquals( 'Initial text', $parts[0]->getText() ); + $this->assertEquals( ' Additional text', $parts[1]->getText() ); + } + + /** + * Tests withFile method with base64 data. + * + * @ticket TBD + */ + public function test_with_inline_file() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $result = $builder->with_file( $base64, 'image/png' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'data:image/png;base64,' . $base64, $file->getDataUri() ); + $this->assertEquals( 'image/png', $file->getMimeType() ); + } + + /** + * Tests withFile method with remote URL. + * + * @ticket TBD + */ + public function test_with_remote_file() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_file( 'https://example.com/image.jpg', 'image/jpeg' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'https://example.com/image.jpg', $file->getUrl() ); + $this->assertEquals( 'image/jpeg', $file->getMimeType() ); + } + + /** + * Tests withFile with data URI. + * + * @ticket TBD + */ + public function test_with_inline_file_data_uri() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $data_uri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; + $result = $builder->with_file( $data_uri ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'image/jpeg', $file->getMimeType() ); + } + + /** + * Tests withFile with URL without explicit MIME type. + * + * @ticket TBD + */ + public function test_with_remote_file_without_mime_type() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_file( 'https://example.com/audio.mp3' ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf( File::class, $file ); + $this->assertEquals( 'https://example.com/audio.mp3', $file->getUrl() ); + $this->assertEquals( 'audio/mpeg', $file->getMimeType() ); + } + + /** + * Tests withFunctionResponse method. + * + * @ticket TBD + */ + public function test_with_function_response() { + $function_response = new FunctionResponse( 'func_id', 'func_name', array( 'result' => 'data' ) ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_function_response( $function_response ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertSame( $function_response, $messages[0]->getParts()[0]->getFunctionResponse() ); + } + + /** + * Tests withMessageParts method. + * + * @ticket TBD + */ + public function test_with_message_parts() { + $part1 = new MessagePart( 'Part 1' ); + $part2 = new MessagePart( 'Part 2' ); + $part3 = new MessagePart( 'Part 3' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_message_parts( $part1, $part2, $part3 ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'Part 1', $parts[0]->getText() ); + $this->assertEquals( 'Part 2', $parts[1]->getText() ); + $this->assertEquals( 'Part 3', $parts[2]->getText() ); + } + + /** + * Tests withHistory method. + * + * @ticket TBD + */ + public function test_with_history() { + $history = array( + new UserMessage( array( new MessagePart( 'User 1' ) ) ), + new ModelMessage( array( new MessagePart( 'Model 1' ) ) ), + new UserMessage( array( new MessagePart( 'User 2' ) ) ), + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->with_history( ...$history ); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 3, $messages ); + $this->assertEquals( 'User 1', $messages[0]->getParts()[0]->getText() ); + $this->assertEquals( 'Model 1', $messages[1]->getParts()[0]->getText() ); + $this->assertEquals( 'User 2', $messages[2]->getParts()[0]->getText() ); + } + + /** + * Tests usingModel method. + * + * @ticket TBD + */ + public function test_using_model() { + $model_config = new ModelConfig(); + $model = $this->createMock( ModelInterface::class ); + $model->method( 'getConfig' )->willReturn( $model_config ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_model( $model ); + + $this->assertSame( $builder, $result ); + + /** @var ModelInterface $actual_model */ + $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' ); + $this->assertSame( $model, $actual_model ); + } + + /** + * Tests constructor with list of string parts. + * + * @ticket TBD + */ + public function test_constructor_with_string_parts_list() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'Part 1', 'Part 2', 'Part 3' ) ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $this->assertInstanceOf( Message::class, $messages[0] ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'Part 1', $parts[0]->getText() ); + $this->assertEquals( 'Part 2', $parts[1]->getText() ); + $this->assertEquals( 'Part 3', $parts[2]->getText() ); + } + + /** + * Tests constructor with mixed parts list. + * + * @ticket TBD + */ + public function test_constructor_with_mixed_parts_list() { + $part1 = new MessagePart( 'Part 1' ); + $part2_array = array( + 'type' => 'text', + 'text' => 'Part 2', + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array( 'String part', $part1, $part2_array ) ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + + $this->assertCount( 1, $messages ); + $parts = $messages[0]->getParts(); + $this->assertCount( 3, $parts ); + $this->assertEquals( 'String part', $parts[0]->getText() ); + $this->assertEquals( 'Part 1', $parts[1]->getText() ); + $this->assertEquals( 'Part 2', $parts[2]->getText() ); + } + + /** + * Tests full method chaining. + * + * @ticket TBD + */ + public function test_method_chaining() { + $model = $this->createMock( ModelInterface::class ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder + ->with_text( 'Start of prompt' ) + ->with_file( 'https://example.com/img.jpg', 'image/jpeg' ) + ->using_model( $model ) + ->using_system_instruction( 'Be helpful' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.8 ) + ->using_top_p( 0.95 ) + ->using_top_k( 50 ) + ->using_candidate_count( 2 ) + ->as_json_response(); + + $this->assertSame( $builder, $result ); + + /** @var list $messages */ + $messages = $this->get_wrapped_prompt_builder_property_value( $builder, 'messages' ); + $this->assertCount( 1, $messages ); + $this->assertCount( 2, $messages[0]->getParts() ); + + /** @var ModelInterface $actual_model */ + $actual_model = $this->get_wrapped_prompt_builder_property_value( $builder, 'model' ); + $this->assertSame( $model, $actual_model ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'Be helpful', $config->getSystemInstruction() ); + $this->assertEquals( 500, $config->getMaxTokens() ); + $this->assertEquals( 0.8, $config->getTemperature() ); + $this->assertEquals( 0.95, $config->getTopP() ); + $this->assertEquals( 50, $config->getTopK() ); + $this->assertEquals( 2, $config->getCandidateCount() ); + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests usingModelPreference skips unavailable model IDs and falls back. + * + * @ticket TBD + */ + public function test_using_model_preference_skips_unavailable_model_id() { + $result = $this->create_test_result( 'Fallback model result' ); + $other_metadata = $this->create_text_model_metadata_with_input_support( 'other-id' ); + $fallback_metadata = $this->create_text_model_metadata_with_input_support( 'fallback-id' ); + $model = $this->create_mock_text_generation_model( $result, $fallback_metadata ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderId' ) + ->with( 'test-provider' ) + ->willReturn( 'test-provider' ); + + $this->registry->expects( $this->once() ) + ->method( 'findProviderModelsMetadataForSupport' ) + ->with( 'test-provider', $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $other_metadata, $fallback_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( 'test-provider', 'fallback-id', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_provider( 'test-provider' ); + $builder->using_model_preference( 'missing-id', 'fallback-id' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference falls back to discovery when no preferences available. + * + * @ticket TBD + */ + public function test_using_model_preference_falls_back_to_discovery() { + $result = $this->create_test_result( 'Discovered model result' ); + $metadata = $this->create_text_model_metadata_with_input_support( 'discovered-id' ); + $provider_metadata = $this->create_test_provider_metadata(); + $provider_models_metadata = new ProviderModelsMetadata( $provider_metadata, array( $metadata ) ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $this->registry->expects( $this->once() ) + ->method( 'findModelsMetadataForSupport' ) + ->with( $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $provider_models_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( $provider_metadata->getId(), 'discovered-id', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findProviderModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model_preference( 'unavailable-model' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference respects priority order when multiple preferred models are available. + * + * @ticket TBD + */ + public function test_using_model_preference_respects_order_when_multiple_available() { + $result = $this->create_test_result( 'Second choice result' ); + $second_choice_metadata = $this->create_text_model_metadata_with_input_support( 'second-choice' ); + $third_choice_metadata = $this->create_text_model_metadata_with_input_support( 'third-choice' ); + $provider_metadata = $this->create_test_provider_metadata(); + + $model = $this->create_mock_text_generation_model( $result, $second_choice_metadata ); + + $provider_models_metadata = new ProviderModelsMetadata( + $provider_metadata, + array( $third_choice_metadata, $second_choice_metadata ) + ); + + $this->registry->expects( $this->once() ) + ->method( 'findModelsMetadataForSupport' ) + ->with( $this->isInstanceOf( ModelRequirements::class ) ) + ->willReturn( array( $provider_models_metadata ) ); + + $this->registry->expects( $this->once() ) + ->method( 'getProviderModel' ) + ->with( $provider_metadata->getId(), 'second-choice', $this->isInstanceOf( ModelConfig::class ) ) + ->willReturn( $model ); + + $this->registry->expects( $this->never() ) + ->method( 'findProviderModelsMetadataForSupport' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model_preference( 'first-choice', 'second-choice', 'third-choice' ); + + $actual_result = $builder->generate_text_result(); + + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests usingModelPreference rejects invalid preference types, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_invalid_type_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( 123 ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preferences must be model identifiers', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects malformed preference tuples, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_invalid_tuple_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( + array( + 'provider' => 'test', + 'model' => 'id', + ) + ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preference tuple must contain model identifier and provider ID.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects empty preference identifiers, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_with_empty_identifier_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference( ' ' ); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'Model preference identifiers cannot be empty.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelPreference rejects calls without preferences, returning WP_Error. + * + * @ticket TBD + */ + public function test_using_model_preference_without_arguments_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_model_preference(); + $result = $builder->generate_text_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( + 'At least one model preference must be provided.', + $result->get_error_message() + ); + } + + /** + * Tests usingModelConfig method. + * + * @ticket TBD + */ + public function test_using_model_config() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $builder->using_system_instruction( 'Builder instruction' ) + ->using_max_tokens( 500 ) + ->using_temperature( 0.5 ); + + $config = new ModelConfig(); + $config->setSystemInstruction( 'Config instruction' ); + $config->setMaxTokens( 1000 ); + $config->setTopP( 0.9 ); + $config->setTopK( 40 ); + + $result = $builder->using_model_config( $config ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'Builder instruction', $merged_config->getSystemInstruction() ); + $this->assertEquals( 500, $merged_config->getMaxTokens() ); + $this->assertEquals( 0.5, $merged_config->getTemperature() ); + $this->assertEquals( 0.9, $merged_config->getTopP() ); + $this->assertEquals( 40, $merged_config->getTopK() ); + } + + /** + * Tests usingModelConfig with custom options. + * + * @ticket TBD + */ + public function test_using_model_config_with_custom_options() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $config = new ModelConfig(); + $config->setCustomOption( 'stopSequences', array( 'CONFIG_STOP' ) ); + $config->setCustomOption( 'otherOption', 'value' ); + + $builder->using_model_config( $config ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + $custom_options = $merged_config->getCustomOptions(); + + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertIsArray( $custom_options['stopSequences'] ); + $this->assertEquals( array( 'CONFIG_STOP' ), $custom_options['stopSequences'] ); + $this->assertArrayHasKey( 'otherOption', $custom_options ); + $this->assertEquals( 'value', $custom_options['otherOption'] ); + + $builder->using_stop_sequences( 'STOP' ); + + /** @var ModelConfig $merged_config */ + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + $custom_options = $merged_config->getCustomOptions(); + + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertIsArray( $custom_options['stopSequences'] ); + $this->assertEquals( array( 'STOP' ), $custom_options['stopSequences'] ); + $this->assertArrayHasKey( 'otherOption', $custom_options ); + $this->assertEquals( 'value', $custom_options['otherOption'] ); + } + + /** + * Tests usingProvider method. + * + * @ticket TBD + */ + public function test_using_provider() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_provider( 'test-provider' ); + + $this->assertSame( $builder, $result ); + + $actual_provider = $this->get_wrapped_prompt_builder_property_value( $builder, 'providerIdOrClassName' ); + $this->assertEquals( 'test-provider', $actual_provider ); + } + + /** + * Tests usingSystemInstruction method. + * + * @ticket TBD + */ + public function test_using_system_instruction() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_system_instruction( 'You are a helpful assistant.' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'You are a helpful assistant.', $config->getSystemInstruction() ); + } + + /** + * Tests usingMaxTokens method. + * + * @ticket TBD + */ + public function test_using_max_tokens() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_max_tokens( 1000 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 1000, $config->getMaxTokens() ); + } + + /** + * Tests usingTemperature method. + * + * @ticket TBD + */ + public function test_using_temperature() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_temperature( 0.7 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 0.7, $config->getTemperature() ); + } + + /** + * Tests usingTopP method. + * + * @ticket TBD + */ + public function test_using_top_p() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_top_p( 0.9 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 0.9, $config->getTopP() ); + } + + /** + * Tests usingTopK method. + * + * @ticket TBD + */ + public function test_using_top_k() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_top_k( 40 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 40, $config->getTopK() ); + } + + /** + * Tests usingStopSequences method. + * + * @ticket TBD + */ + public function test_using_stop_sequences() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_stop_sequences( 'STOP', 'END', '###' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $custom_options = $config->getCustomOptions(); + $this->assertArrayHasKey( 'stopSequences', $custom_options ); + $this->assertEquals( array( 'STOP', 'END', '###' ), $custom_options['stopSequences'] ); + } + + /** + * Tests usingCandidateCount method. + * + * @ticket TBD + */ + public function test_using_candidate_count() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_candidate_count( 3 ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 3, $config->getCandidateCount() ); + } + + /** + * Tests asOutputMimeType method. + * + * @ticket TBD + */ + public function test_using_output_mime() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_mime_type( 'application/json' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests asOutputSchema method. + * + * @ticket TBD + */ + public function test_using_output_schema() { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + ), + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_schema( $schema ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( $schema, $config->getOutputSchema() ); + } + + /** + * Tests asOutputModalities method. + * + * @ticket TBD + */ + public function test_using_output_modalities() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_modalities( + ModalityEnum::text(), + ModalityEnum::image() + ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertCount( 2, $modalities ); + $this->assertTrue( $modalities[0]->isText() ); + $this->assertTrue( $modalities[1]->isImage() ); + } + + /** + * Tests asJsonResponse method. + * + * @ticket TBD + */ + public function test_as_json_response() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_json_response(); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + } + + /** + * Tests asJsonResponse with schema. + * + * @ticket TBD + */ + public function test_as_json_response_with_schema() { + $schema = array( 'type' => 'array' ); + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_json_response( $schema ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'application/json', $config->getOutputMimeType() ); + $this->assertEquals( $schema, $config->getOutputSchema() ); + } + + /** + * Tests validateMessages with empty messages returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_empty_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'Cannot generate from an empty prompt', $result->get_error_message() ); + } + + /** + * Tests validateMessages with non-user first message returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_non_user_first_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( + $this->registry, + array( + new ModelMessage( array( new MessagePart( 'Model says hi' ) ) ), + new UserMessage( array( new MessagePart( 'User response' ) ) ), + ) + ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'The first message must be from a user role', $result->get_error_message() ); + } + + /** + * Tests validateMessages with non-user last message returns WP_Error. + * + * @ticket TBD + */ + public function test_validate_messages_non_user_last_returns_wp_error() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $builder->with_text( 'Initial user message' ); + + $builder->with_history( + new UserMessage( array( new MessagePart( 'Historical user message' ) ) ), + new ModelMessage( array( new MessagePart( 'Historical model response' ) ) ) + ); + + // Manually add a model message as the last message. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection_class->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped_builder = $builder_property->getValue( $builder ); + $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); + $messages_property = $reflection_class2->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + + $messages = $messages_property->getValue( $wrapped_builder ); + $messages[] = new ModelMessage( array( new MessagePart( 'Final model message' ) ) ); + $messages_property->setValue( $wrapped_builder, $messages ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'The last message must be from a user role', $result->get_error_message() ); + } + + /** + * Tests parseMessage with empty string returns WP_Error on termination. + * + * The SDK constructor throws immediately for empty strings, so the exception + * is caught in the constructor and stored. + * + * @ticket TBD + */ + public function test_parse_message_empty_string_returns_wp_error() { + // The empty string exception is thrown by the SDK's PromptBuilder constructor, + // which happens before our __call() error handling. We must catch it manually. + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, ' ' ); + // If we get here, the SDK didn't throw. Test would need adjusting. + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Cannot create a message from an empty string', $e->getMessage() ); + } + } + + /** + * Tests parseMessage with empty array returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_parse_message_empty_array_returns_wp_error() { + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, array() ); + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Cannot create a message from an empty array', $e->getMessage() ); + } + } + + /** + * Tests parseMessage with invalid type returns WP_Error on termination. + * + * @ticket TBD + */ + public function test_parse_message_invalid_type_returns_wp_error() { + try { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 123 ); + $result = $builder->generate_result(); + $this->assertWPError( $result ); + } catch ( InvalidArgumentException $e ) { + $this->assertStringContainsString( 'Input must be a string, MessagePart, MessagePartArrayShape', $e->getMessage() ); + } + } + + /** + * Tests generateResult with text output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_text_modality() { + $result = $this->createMock( GenerativeAiResult::class ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with image output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_image_modality() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate an image' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::image() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with audio output modality. + * + * @ticket TBD + */ + public function test_generate_result_with_audio_modality() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:audio/wav;base64,UklGRigE=', 'audio/wav' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::audio() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult with multimodal output. + * + * @ticket TBD + */ + public function test_generate_result_with_multimodal_output() { + $result = new GenerativeAiResult( + 'test-result', + array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate multimodal' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::text(), ModalityEnum::image() ); + + $actual_result = $builder->generate_result(); + $this->assertSame( $result, $actual_result ); + } + + /** + * Tests generateResult returns WP_Error when model does not support modality. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_for_unsupported_modality() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->createMock( ModelInterface::class ); + $model->method( 'metadata' )->willReturn( $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'does not support text generation', $result->get_error_message() ); + } + + /** + * Tests generateResult returns WP_Error for unsupported output modality. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_for_unsupported_output_modality() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->createMock( ModelInterface::class ); + $model->method( 'metadata' )->willReturn( $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + $builder->as_output_modalities( ModalityEnum::video() ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'Output modality "video" is not yet supported', $result->get_error_message() ); + } + + /** + * Tests generateTextResult method. + * + * @ticket TBD + */ + public function test_generate_text_result() { + $result = new GenerativeAiResult( + 'test-result', + array( new Candidate( new ModelMessage( array( new MessagePart( 'Generated text' ) ) ), FinishReasonEnum::stop() ) ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text_result(); + $this->assertSame( $result, $actual_result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull( $modalities ); + $this->assertTrue( $modalities[0]->isText() ); + } + + /** + * Tests generateImageResult method. + * + * @ticket TBD + */ + public function test_generate_image_result() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:image/png;base64,iVBORw0KGgo=', 'image/png' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_image_result(); + $this->assertSame( $result, $actual_result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull( $modalities ); + $this->assertTrue( $modalities[0]->isImage() ); + } + + /** + * Tests generateText returns WP_Error when no candidates. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_no_candidates() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model_with_exception( + new RuntimeException( 'No candidates were generated' ), + $metadata + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $result = $builder->generate_text(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'No candidates were generated', $result->get_error_message() ); + } + + /** + * Tests generateText returns WP_Error when message has no parts. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_no_parts() { + $message = new ModelMessage( array() ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateText returns WP_Error when part has no text. + * + * @ticket TBD + */ + public function test_generate_text_returns_wp_error_when_part_has_no_text() { + $file = new File( 'https://example.com/image.jpg', 'image/jpeg' ); + $message_part = new MessagePart( $file ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate text' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_text(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No text content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateTexts method. + * + * @ticket TBD + */ + public function test_generate_texts() { + $candidates = array( + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 1' ) ) ), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 2' ) ) ), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage( array( new MessagePart( 'Text 3' ) ) ), + FinishReasonEnum::stop() + ), + ); + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' ); + $builder->using_model( $model ); + + $texts = $builder->generate_texts( 3 ); + + $this->assertCount( 3, $texts ); + $this->assertEquals( 'Text 1', $texts[0] ); + $this->assertEquals( 'Text 2', $texts[1] ); + $this->assertEquals( 'Text 3', $texts[2] ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 3, $config->getCandidateCount() ); + } + + /** + * Tests generateTexts returns WP_Error when no text generated. + * + * @ticket TBD + */ + public function test_generate_texts_returns_wp_error_when_no_text_generated() { + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_generation_model_with_exception( + new RuntimeException( 'No text was generated from any candidates' ), + $metadata + ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate texts' ); + $builder->using_model( $model ); + + $result = $builder->generate_texts(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertStringContainsString( 'No text was generated from any candidates', $result->get_error_message() ); + } + + /** + * Tests generateImage method. + * + * @ticket TBD + */ + public function test_generate_image() { + $file = new File( 'https://example.com/generated.jpg', 'image/jpeg' ); + $message_part = new MessagePart( $file ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $generated_file = $builder->generate_image(); + $this->assertSame( $file, $generated_file ); + } + + /** + * Tests generateImage returns WP_Error when no image file. + * + * @ticket TBD + */ + public function test_generate_image_returns_wp_error_when_no_file() { + $message_part = new MessagePart( 'Text instead of image' ); + $message = new ModelMessage( array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate image' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_image(); + + $this->assertWPError( $actual_result ); + $this->assertSame( 'prompt_builder_error', $actual_result->get_error_code() ); + $this->assertStringContainsString( 'No file content found in first candidate', $actual_result->get_error_message() ); + } + + /** + * Tests generateImages method. + * + * @ticket TBD + */ + public function test_generate_images() { + $files = array( + new File( 'https://example.com/img1.jpg', 'image/jpeg' ), + new File( 'https://example.com/img2.jpg', 'image/jpeg' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_image_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate images' ); + $builder->using_model( $model ); + + $generated_files = $builder->generate_images( 2 ); + + $this->assertCount( 2, $generated_files ); + $this->assertSame( $files[0], $generated_files[0] ); + $this->assertSame( $files[1], $generated_files[1] ); + } + + /** + * Tests convertTextToSpeech method. + * + * @ticket TBD + */ + public function test_convert_text_to_speech() { + $file = new File( 'https://example.com/audio.mp3', 'audio/mp3' ); + $message_part = new MessagePart( $file ); + $message = new Message( MessageRoleEnum::model(), array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_to_speech_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' ); + $builder->using_model( $model ); + + $audio_file = $builder->convert_text_to_speech(); + $this->assertSame( $file, $audio_file ); + } + + /** + * Tests convertTextToSpeeches method. + * + * @ticket TBD + */ + public function test_convert_text_to_speeches() { + $files = array( + new File( 'https://example.com/audio1.mp3', 'audio/mp3' ), + new File( 'https://example.com/audio2.mp3', 'audio/mp3' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_text_to_speech_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Convert this text' ); + $builder->using_model( $model ); + + $audio_files = $builder->convert_text_to_speeches( 2 ); + + $this->assertCount( 2, $audio_files ); + $this->assertSame( $files[0], $audio_files[0] ); + $this->assertSame( $files[1], $audio_files[1] ); + } + + /** + * Tests generateSpeech method. + * + * @ticket TBD + */ + public function test_generate_speech() { + $file = new File( 'https://example.com/speech.mp3', 'audio/mp3' ); + $message_part = new MessagePart( $file ); + $message = new Message( MessageRoleEnum::model(), array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + + $speech_file = $builder->generate_speech(); + $this->assertSame( $file, $speech_file ); + } + + /** + * Tests generateSpeeches method. + * + * @ticket TBD + */ + public function test_generate_speeches() { + $files = array( + new File( 'https://example.com/speech1.mp3', 'audio/mp3' ), + new File( 'https://example.com/speech2.mp3', 'audio/mp3' ), + new File( 'https://example.com/speech3.mp3', 'audio/mp3' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop(), + 10 + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_text_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_speech_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate speech' ); + $builder->using_model( $model ); + + $speech_files = $builder->generate_speeches( 3 ); + + $this->assertCount( 3, $speech_files ); + $this->assertSame( $files[0], $speech_files[0] ); + $this->assertSame( $files[1], $speech_files[1] ); + $this->assertSame( $files[2], $speech_files[2] ); + } + + /** + * Tests using_abilities with ability name string. + * + * @ticket TBD + */ + public function test_using_ability_with_string() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( 'wpaiclienttests/simple' ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'A simple test ability with no parameters.', $declarations[0]->getDescription() ); + } + + /** + * Tests using_abilities with WP_Ability object. + * + * @ticket TBD + */ + public function test_using_ability_with_wp_ability_object() { + $ability = wp_get_ability( 'wpaiclienttests/with-params' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( $ability ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[0]->getName() ); + $this->assertEquals( 'A test ability that accepts parameters.', $declarations[0]->getDescription() ); + + $params = $declarations[0]->getParameters(); + $this->assertNotNull( $params ); + $this->assertArrayHasKey( 'properties', $params ); + $this->assertArrayHasKey( 'title', $params['properties'] ); + } + + /** + * Tests using_abilities with multiple abilities. + * + * @ticket TBD + */ + public function test_using_ability_with_multiple_abilities() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + 'wpaiclienttests/with-params', + 'wpaiclienttests/returns-error' + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 3, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__returns-error', $declarations[2]->getName() ); + } + + /** + * Tests using_abilities skips non-existent abilities. + * + * @ticket TBD + */ + public function test_using_ability_skips_nonexistent_abilities() { + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::get_registered' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + 'nonexistent/ability', + 'wpaiclienttests/with-params' + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 2, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + } + + /** + * Tests using_abilities with empty arguments returns self. + * + * @ticket TBD + */ + public function test_using_ability_with_no_arguments_returns_self() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities(); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNull( $declarations ); + } + + /** + * Tests using_abilities with mixed strings and WP_Ability objects. + * + * @ticket TBD + */ + public function test_using_ability_with_mixed_types() { + $ability = wp_get_ability( 'wpaiclienttests/with-params' ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( + 'wpaiclienttests/simple', + $ability + ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 2, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + $this->assertEquals( 'wpab__wpaiclienttests__with-params', $declarations[1]->getName() ); + } + + /** + * Tests using_abilities with hyphenated ability name. + * + * @ticket TBD + */ + public function test_using_ability_with_hyphenated_name() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->using_abilities( 'wpaiclienttests/hyphen-test' ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__hyphen-test', $declarations[0]->getName() ); + } + + /** + * Tests using_abilities can be chained with other methods. + * + * @ticket TBD + */ + public function test_using_ability_method_chaining() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder + ->with_text( 'Test prompt' ) + ->using_abilities( 'wpaiclienttests/simple' ) + ->using_system_instruction( 'You are a helpful assistant' ) + ->using_max_tokens( 500 ); + + $this->assertSame( $builder, $result ); + + $declarations = $this->get_function_declarations( $builder ); + + $this->assertNotNull( $declarations ); + $this->assertCount( 1, $declarations ); + $this->assertEquals( 'wpab__wpaiclienttests__simple', $declarations[0]->getName() ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'You are a helpful assistant', $config->getSystemInstruction() ); + $this->assertEquals( 500, $config->getMaxTokens() ); + } + + /** + * Tests that is_supported returns false when prevent prompt filter returns true. + * + * @ticket TBD + */ + public function test_is_supported_returns_false_when_filter_prevents_prompt() { + add_filter( 'wp_ai_client_prevent_prompt', '__return_true' ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + $this->assertFalse( $builder->is_supported() ); + } + + /** + * Tests that generate_result returns WP_Error when prevent prompt filter returns true. + * + * @ticket TBD + */ + public function test_generate_result_returns_wp_error_when_filter_prevents_prompt() { + add_filter( 'wp_ai_client_prevent_prompt', '__return_true' ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + $result = $builder->generate_result(); + + $this->assertWPError( $result ); + $this->assertSame( 'prompt_prevented', $result->get_error_code() ); + $this->assertSame( 'Prompt execution was prevented by a filter.', $result->get_error_message() ); + } + + /** + * Tests that prevent prompt filter receives a clone of the builder instance. + * + * @ticket TBD + */ + public function test_prevent_prompt_filter_receives_cloned_builder_instance() { + $captured_builder = null; + + add_filter( + 'wp_ai_client_prevent_prompt', + static function ( $prevent, $builder ) use ( &$captured_builder ) { + $captured_builder = $builder; + return $prevent; + }, + 10, + 2 + ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + // Test with is_supported(). + $builder->is_supported(); + $this->assertNotSame( $builder, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder ); + + // Reset and test with generate_result(). + $captured_builder = null; + $builder2 = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + $builder2->generate_result(); + $this->assertNotSame( $builder2, $captured_builder, 'Filter should receive a clone, not the same instance' ); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder ); + } + + /** + * Tests that once in error state, subsequent fluent calls return the same instance. + * + * @ticket TBD + */ + public function test_error_state_fluent_calls_return_same_instance() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Simulate an error state by directly setting the error property. + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $error_property = $reflection_class->getProperty( 'error' ); + $error_property->setAccessible( true ); + $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) ); + + $result = $prompt_builder->with_text( 'Test' ); + $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); + + $result = $prompt_builder->using_max_tokens( 100 ); + $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); + } + + /** + * Tests that terminating methods return WP_Error when in error state. + * + * @ticket TBD + */ + public function test_terminating_methods_return_wp_error_in_error_state() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $test_error = new WP_Error( 'test_error', 'Test error message' ); + $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $error_property = $reflection_class->getProperty( 'error' ); + $error_property->setAccessible( true ); + $error_property->setValue( $prompt_builder, $test_error ); + + $result = $prompt_builder->generate_text(); + $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' ); + $this->assertSame( $test_error, $result, 'Should return the same WP_Error instance' ); + } + + /** + * Tests that exception in terminating method is caught and returned as WP_Error. + * + * @ticket TBD + */ + public function test_exception_in_terminating_method_caught_and_returned() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $error = $prompt_builder->generate_text(); + + $this->assertWPError( $error, 'generate_text should return WP_Error when exception occurs' ); + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + + $error_data = $error->get_error_data(); + $this->assertIsArray( $error_data ); + $this->assertArrayHasKey( 'exception_class', $error_data ); + $this->assertNotEmpty( $error_data['exception_class'] ); + } + + /** + * Tests that exception in chained method is caught and returned by the terminating method as WP_Error. + * + * @ticket TBD + */ + public function test_exception_in_chained_method_caught_and_returned_by_terminating_method() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + $result = $prompt_builder + ->with_text( 'Start of prompt' ) + ->with_file( 'https://example.com/img.jpg', 'image/jpeg' ) + // Invalid: Only provider and model ID must be given. + ->using_model_preference( array( 'test-provider', 'test-model', 'test-version' ) ) + ->using_system_instruction( 'Be helpful' ) + ->generate_text(); + + $this->assertWPError( $result, 'generate_text should return WP_Error when exception occurs' ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); + $this->assertSame( 'Model preference tuple must contain model identifier and provider ID.', $result->get_error_message() ); + + $error_data = $result->get_error_data(); + $this->assertIsArray( $error_data ); + $this->assertArrayHasKey( 'exception_class', $error_data ); + $this->assertNotEmpty( $error_data['exception_class'] ); + } +} From 1c07c3ecd1885838600e2ab4ba38c27864730866 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 13:05:44 -0700 Subject: [PATCH 025/147] refactor: moves prompt builder and renames directory --- phpunit.xml.dist | 2 +- ...wp-ai-client-ability-function-resolver.php | 0 .../class-wp-ai-client-discovery-strategy.php | 0 .../class-wp-ai-client-event-dispatcher.php | 0 .../class-wp-ai-client-http-client.php | 0 .../class-wp-ai-client-psr17-factory.php | 0 .../class-wp-ai-client-psr7-request.php | 0 .../class-wp-ai-client-psr7-response.php | 0 .../class-wp-ai-client-psr7-stream.php | 0 .../class-wp-ai-client-psr7-uri.php | 0 .../class-wp-ai-client-prompt-builder.php | 0 src/wp-settings.php | 22 +++++++++---------- 12 files changed, 12 insertions(+), 12 deletions(-) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-ability-function-resolver.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-discovery-strategy.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-event-dispatcher.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-http-client.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr17-factory.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-request.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-response.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-stream.php (100%) rename src/wp-includes/{ai-client => ai-client-utils}/class-wp-ai-client-psr7-uri.php (100%) rename src/wp-includes/{ai-client => }/class-wp-ai-client-prompt-builder.php (100%) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2ba1cf60023df..fa1b8805a91ec 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,7 +48,7 @@ src/wp-includes/PHPMailer src/wp-includes/Requests src/wp-includes/php-ai-client - src/wp-includes/ai-client + src/wp-includes/ai-client-utils src/wp-includes/SimplePie src/wp-includes/sodium_compat src/wp-includes/Text diff --git a/src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-discovery-strategy.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-event-dispatcher.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-http-client.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr17-factory.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-request.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-response.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-stream.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-psr7-uri.php rename to src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php similarity index 100% rename from src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php rename to src/wp-includes/class-wp-ai-client-prompt-builder.php diff --git a/src/wp-settings.php b/src/wp-settings.php index 23153988bee04..5b672b6698c92 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -289,20 +289,20 @@ require ABSPATH . WPINC . '/php-ai-client/autoload.php'; // WP AI Client - PSR-7 implementations. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-stream.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-uri.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-request.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr7-response.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-psr17-factory.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-stream.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-uri.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-request.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr7-response.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-psr17-factory.php'; // WP AI Client - HTTP transport and infrastructure. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-http-client.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-discovery-strategy.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-event-dispatcher.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-http-client.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-discovery-strategy.php'; +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-event-dispatcher.php'; -// WP AI Client - Prompt builder. -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; -require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; +// WP AI Client - Abilities and prompt builder. +require ABSPATH . WPINC . '/ai-client-utils/class-wp-ai-client-ability-function-resolver.php'; +require ABSPATH . WPINC . '/class-wp-ai-client-prompt-builder.php'; // WP AI Client - Initialization. WP_AI_Client_Discovery_Strategy::init(); From 23f1af0cdcf4171831d8741f847849d5d6f3a404 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 13:15:17 -0700 Subject: [PATCH 026/147] fix: handles support methods in an error state --- .../class-wp-ai-client-prompt-builder.php | 61 +++++++++++-------- .../ai-client/wpAiClientPromptBuilder.php | 36 +++++++---- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index e34e15e11936f..999adeb6e9f90 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -32,11 +32,11 @@ * handling instead of exceptions, snake_case method naming, and integration * with the Abilities API. * - * Only the terminate methods will return a WP_Error, to not break the fluent + * Only the generating methods will return a WP_Error, to not break the fluent * interface. As soon as any exception is caught in a chain of method calls, * the returned instance will be in an error state, and all subsequent method * calls will be no-ops that just return the same error state instance. Only - * when a terminate method is called, the WP_Error will be returned. + * when a generating method is called, the WP_Error will be returned. * * @since 6.8.0 * @@ -108,14 +108,14 @@ class WP_AI_Client_Prompt_Builder { private ?WP_Error $error = null; /** - * List of methods that terminate the fluent interface and return a result. + * List of methods that generate a result from the prompt. * * Structured as a map for faster lookups. * * @since 6.8.0 * @var array */ - private static array $terminate_methods = array( + private static array $generating_methods = array( 'generate_result' => true, 'generate_text_result' => true, 'generate_image_result' => true, @@ -131,6 +131,25 @@ class WP_AI_Client_Prompt_Builder { 'generate_speeches' => true, ); + /** + * List of methods that check whether the prompt is supported. + * + * Structured as a map for faster lookups. + * + * @since 6.8.0 + * @var array + */ + private static array $support_check_methods = array( + 'is_supported' => true, + 'is_supported_for_text_generation' => true, + 'is_supported_for_image_generation' => true, + 'is_supported_for_text_to_speech_conversion' => true, + 'is_supported_for_video_generation' => true, + 'is_supported_for_speech_generation' => true, + 'is_supported_for_music_generation' => true, + 'is_supported_for_embedding_generation' => true, + ); + /** * Constructor. * @@ -219,14 +238,17 @@ public function __call( string $name, array $arguments ) { * or return the same instance for other methods to maintain the fluent interface. */ if ( null !== $this->error ) { - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } + if ( self::is_support_check_method( $name ) ) { + return false; + } return $this; } // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. - if ( $this->is_support_check_method( $name ) || $this->is_generating_method( $name ) ) { + if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { /** * Filters whether to prevent the prompt from being executed. * @@ -239,7 +261,7 @@ public function __call( string $name, array $arguments ) { if ( $prevent ) { // For is_supported* methods, return false. - if ( $this->is_support_check_method( $name ) ) { + if ( self::is_support_check_method( $name ) ) { return false; } @@ -252,7 +274,7 @@ public function __call( string $name, array $arguments ) { ) ); - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } return $this; @@ -278,7 +300,7 @@ public function __call( string $name, array $arguments ) { ) ); - if ( self::is_terminating_method( $name ) ) { + if ( self::is_generating_method( $name ) ) { return $this->error; } return $this; @@ -293,8 +315,8 @@ public function __call( string $name, array $arguments ) { * @param string $name The method name. * @return bool True if the method is a support check method, false otherwise. */ - protected function is_support_check_method( string $name ): bool { - return str_starts_with( $name, 'is_supported' ); + private static function is_support_check_method( string $name ): bool { + return isset( self::$support_check_methods[ $name ] ); } /** @@ -305,21 +327,8 @@ protected function is_support_check_method( string $name ): bool { * @param string $name The method name. * @return bool True if the method is a generating method, false otherwise. */ - protected function is_generating_method( string $name ): bool { - return str_starts_with( $name, 'generate_' ) - || str_starts_with( $name, 'convert_text_to_speech' ); - } - - /** - * Checks if a method is a terminating method. - * - * @since 6.8.0 - * - * @param string $name The method name. - * @return bool True if the method is a terminating method, false otherwise. - */ - private static function is_terminating_method( string $name ): bool { - return isset( self::$terminate_methods[ $name ] ); + private static function is_generating_method( string $name ): bool { + return isset( self::$generating_methods[ $name ] ); } /** diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index b44417bae77b3..971c44d02fb4c 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2324,11 +2324,8 @@ public function test_error_state_fluent_calls_return_same_instance() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - // Simulate an error state by directly setting the error property. - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $error_property = $reflection_class->getProperty( 'error' ); - $error_property->setAccessible( true ); - $error_property->setValue( $prompt_builder, new WP_Error( 'test_error', 'Test error message' ) ); + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); $result = $prompt_builder->with_text( 'Test' ); $this->assertSame( $prompt_builder, $result, 'Fluent method should return same instance when in error state' ); @@ -2338,23 +2335,36 @@ public function test_error_state_fluent_calls_return_same_instance() { } /** - * Tests that terminating methods return WP_Error when in error state. + * Tests that support check methods return false when in error state. * * @ticket TBD */ - public function test_terminating_methods_return_wp_error_in_error_state() { + public function test_support_check_methods_return_false_in_error_state() { $registry = AiClient::defaultRegistry(); $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); - $test_error = new WP_Error( 'test_error', 'Test error message' ); - $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $error_property = $reflection_class->getProperty( 'error' ); - $error_property->setAccessible( true ); - $error_property->setValue( $prompt_builder, $test_error ); + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); + + $this->assertFalse( $prompt_builder->is_supported(), 'is_supported should return false when in error state' ); + $this->assertFalse( $prompt_builder->is_supported_for_text_generation(), 'is_supported_for_text_generation should return false when in error state' ); + } + + /** + * Tests that generating methods return WP_Error when in error state. + * + * @ticket TBD + */ + public function test_generating_methods_return_wp_error_in_error_state() { + $registry = AiClient::defaultRegistry(); + $prompt_builder = new WP_AI_Client_Prompt_Builder( $registry ); + + // Trigger an error state by calling a nonexistent method. + $prompt_builder->nonexistent_method(); $result = $prompt_builder->generate_text(); $this->assertWPError( $result, 'generate_text should return WP_Error when in error state' ); - $this->assertSame( $test_error, $result, 'Should return the same WP_Error instance' ); + $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); } /** From 42197b59265ef4dc3223ee07816774f6079f3e9a Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 14:28:13 -0700 Subject: [PATCH 027/147] refactor: namespaces PSR classes and corrects versions --- ...wp-ai-client-ability-function-resolver.php | 18 +++--- .../class-wp-ai-client-discovery-strategy.php | 24 ++++---- .../class-wp-ai-client-event-dispatcher.php | 12 ++-- .../class-wp-ai-client-http-client.php | 32 +++++----- .../class-wp-ai-client-psr17-factory.php | 32 +++++----- .../class-wp-ai-client-psr7-request.php | 60 +++++++++---------- .../class-wp-ai-client-psr7-response.php | 48 +++++++-------- .../class-wp-ai-client-psr7-stream.php | 42 ++++++------- .../class-wp-ai-client-psr7-uri.php | 58 +++++++++--------- .../class-wp-ai-client-prompt-builder.php | 30 +++++----- src/wp-includes/php-ai-client/autoload.php | 24 +------- .../php-ai-client/src/AiClient.php | 4 +- .../src/Builders/PromptBuilder.php | 2 +- .../Contracts/ClientWithOptionsInterface.php | 4 +- .../src/Providers/Http/DTO/Request.php | 2 +- .../Http/Exception/NetworkException.php | 2 +- .../src/Providers/Http/HttpTransporter.php | 14 ++--- .../third-party/Http/Client/Exception.php | 2 +- .../Http/Client/Exception/HttpException.php | 4 +- .../Client/Exception/NetworkException.php | 4 +- .../Client/Exception/RequestAwareTrait.php | 2 +- .../Client/Exception/RequestException.php | 4 +- .../Http/Client/HttpAsyncClient.php | 2 +- .../third-party/Http/Client/HttpClient.php | 2 +- .../Client/Promise/HttpFulfilledPromise.php | 2 +- .../Http/Discovery/Composer/Plugin.php | 38 ++++++------ .../Http/Discovery/Psr17Factory.php | 24 ++++---- .../Http/Discovery/Psr17FactoryDiscovery.php | 12 ++-- .../Http/Discovery/Psr18Client.php | 18 +++--- .../Http/Discovery/Psr18ClientDiscovery.php | 2 +- .../Strategy/CommonClassesStrategy.php | 4 +- .../Strategy/CommonPsr17ClassesStrategy.php | 12 ++-- .../EventDispatcherInterface.php | 2 +- .../ListenerProviderInterface.php | 2 +- .../StoppableEventInterface.php | 2 +- .../Http/Client/ClientExceptionInterface.php | 2 +- .../Psr/Http/Client/ClientInterface.php | 6 +- .../Http/Client/NetworkExceptionInterface.php | 6 +- .../Http/Client/RequestExceptionInterface.php | 6 +- .../Psr/Http/Message/MessageInterface.php | 14 ++--- .../Http/Message/RequestFactoryInterface.php | 4 +- .../Psr/Http/Message/RequestInterface.php | 12 ++-- .../Http/Message/ResponseFactoryInterface.php | 4 +- .../Psr/Http/Message/ResponseInterface.php | 6 +- .../Message/ServerRequestFactoryInterface.php | 4 +- .../Http/Message/ServerRequestInterface.php | 16 ++--- .../Http/Message/StreamFactoryInterface.php | 8 +-- .../Psr/Http/Message/StreamInterface.php | 2 +- .../Message/UploadedFileFactoryInterface.php | 4 +- .../Http/Message/UploadedFileInterface.php | 4 +- .../Psr/Http/Message/UriFactoryInterface.php | 4 +- .../Psr/Http/Message/UriInterface.php | 16 ++--- .../Psr/SimpleCache/CacheException.php | 2 +- .../Psr/SimpleCache/CacheInterface.php | 2 +- .../SimpleCache/InvalidArgumentException.php | 4 +- .../includes/wp-ai-client-mock-event.php | 2 +- ...wp-ai-client-mock-model-creation-trait.php | 2 +- tools/php-ai-client/installer.sh | 38 ++++-------- tools/php-ai-client/scoper.inc.php | 18 +----- 59 files changed, 342 insertions(+), 390 deletions(-) diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php index 474314aab498a..e50b86da50165 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-ability-function-resolver.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClient\Messages\DTO\Message; @@ -16,14 +16,14 @@ /** * Resolves and executes WordPress Abilities API function calls from AI models. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Ability_Function_Resolver { /** * Prefix used to identify ability function calls. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private const ABILITY_PREFIX = 'wpab__'; @@ -31,7 +31,7 @@ class WP_AI_Client_Ability_Function_Resolver { /** * Checks if a function call is an ability call. * - * @since 6.8.0 + * @since 7.0.0 * * @param FunctionCall $call The function call to check. * @return bool True if the function call is an ability call, false otherwise. @@ -48,7 +48,7 @@ public static function is_ability_call( FunctionCall $call ): bool { /** * Executes a WordPress ability from a function call. * - * @since 6.8.0 + * @since 7.0.0 * * @param FunctionCall $call The function call to execute. * @return FunctionResponse The response from executing the ability. @@ -107,7 +107,7 @@ public static function execute_ability( FunctionCall $call ): FunctionResponse { /** * Checks if a message contains any ability function calls. * - * @since 6.8.0 + * @since 7.0.0 * * @param Message $message The message to check. * @return bool True if the message contains ability calls, false otherwise. @@ -128,7 +128,7 @@ public static function has_ability_calls( Message $message ): bool { /** * Executes all ability function calls in a message. * - * @since 6.8.0 + * @since 7.0.0 * * @param Message $message The message containing function calls. * @return Message A new message with function responses. @@ -154,7 +154,7 @@ public static function execute_abilities( Message $message ): Message { * * Transforms "tec/create_event" to "wpab__tec__create_event". * - * @since 6.8.0 + * @since 7.0.0 * * @param string $ability_name The ability name to convert. * @return string The function name. @@ -168,7 +168,7 @@ public static function ability_name_to_function_name( string $ability_name ): st * * Transforms "wpab__tec__create_event" to "tec/create_event". * - * @since 6.8.0 + * @since 7.0.0 * * @param string $function_name The function name to convert. * @return string The ability name. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php index 4314609c3a7db..80bdea4968617 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-discovery-strategy.php @@ -4,12 +4,12 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery; use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * Discovery strategy for WordPress HTTP client. @@ -17,14 +17,14 @@ * Registers the WordPress HTTP client adapter with the HTTPlug discovery system * so the AI Client SDK can find and use it automatically. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Discovery_Strategy implements DiscoveryStrategy { /** * Initializes and registers the discovery strategy. * - * @since 6.8.0 + * @since 7.0.0 */ public static function init() { if ( ! class_exists( '\WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery' ) ) { @@ -37,7 +37,7 @@ public static function init() { /** * Gets candidates for discovery. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $type The type of discovery. * @return array> List of candidates. @@ -54,12 +54,12 @@ public static function getCandidates( $type ) { } $psr17_factories = array( - 'Psr\Http\Message\RequestFactoryInterface', - 'Psr\Http\Message\ResponseFactoryInterface', - 'Psr\Http\Message\ServerRequestFactoryInterface', - 'Psr\Http\Message\StreamFactoryInterface', - 'Psr\Http\Message\UploadedFileFactoryInterface', - 'Psr\Http\Message\UriFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface', + 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface', ); if ( in_array( $type, $psr17_factories, true ) ) { @@ -76,7 +76,7 @@ public static function getCandidates( $type ) { /** * Creates an instance of the WordPress HTTP client. * - * @since 6.8.0 + * @since 7.0.0 * * @return WP_AI_Client_HTTP_Client */ diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php index bfe294ed1d92f..9eeb85b32a6c0 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-event-dispatcher.php @@ -4,10 +4,10 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; /** * WordPress-specific PSR-14 event dispatcher for the AI Client. @@ -15,7 +15,7 @@ * Bridges PSR-14 events to WordPress action hooks, enabling plugins to hook * into AI client lifecycle events. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface { @@ -25,7 +25,7 @@ class WP_AI_Client_Event_Dispatcher implements EventDispatcherInterface { * Converts the event class name to a WordPress action hook name and fires it. * For example, BeforeGenerateResultEvent becomes wp_ai_client_before_generate_result. * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object to dispatch. * @return object The same event object, potentially modified by listeners. @@ -47,7 +47,7 @@ public function dispatch( object $event ): object { * - wp_ai_client_before_generate_result * - wp_ai_client_after_generate_result * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object. */ @@ -59,7 +59,7 @@ public function dispatch( object $event ): object { /** * Converts an event object class name to a WordPress action hook name portion. * - * @since 6.8.0 + * @since 7.0.0 * * @param object $event The event object. * @return string The hook name portion derived from the event class name. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php index a49324f130a47..bddcde6cf62c0 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php @@ -4,14 +4,14 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\Exception\NetworkException; @@ -22,14 +22,14 @@ * Allows WordPress HTTP functions to be used as a PSR-18 compliant HTTP client * for the AI Client SDK. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInterface { /** * Response factory instance. * - * @since 6.8.0 + * @since 7.0.0 * @var ResponseFactoryInterface */ private $response_factory; @@ -37,7 +37,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte /** * Stream factory instance. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamFactoryInterface */ private $stream_factory; @@ -45,7 +45,7 @@ class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param ResponseFactoryInterface $response_factory PSR-17 Response factory. * @param StreamFactoryInterface $stream_factory PSR-17 Stream factory. @@ -58,7 +58,7 @@ public function __construct( ResponseFactoryInterface $response_factory, StreamF /** * Sends a PSR-7 request and returns a PSR-7 response. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return ResponseInterface The PSR-7 response. @@ -88,7 +88,7 @@ public function sendRequest( RequestInterface $request ): ResponseInterface { /** * Sends a PSR-7 request with transport options and returns a PSR-7 response. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @param RequestOptions $options Transport options for the request. @@ -121,7 +121,7 @@ public function sendRequestWithOptions( RequestInterface $request, RequestOption /** * Prepares WordPress HTTP API arguments from a PSR-7 request. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @param RequestOptions|null $options Optional transport options for the request. @@ -152,7 +152,7 @@ private function prepare_wp_args( RequestInterface $request, ?RequestOptions $op /** * Prepares headers for WordPress HTTP API. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return array Headers array for WordPress HTTP API. @@ -174,7 +174,7 @@ private function prepare_headers( RequestInterface $request ): array { /** * Prepares request body for WordPress HTTP API. * - * @since 6.8.0 + * @since 7.0.0 * * @param RequestInterface $request The PSR-7 request. * @return string|null The request body. @@ -196,7 +196,7 @@ private function prepare_body( RequestInterface $request ): ?string { /** * Creates a PSR-7 response from a WordPress HTTP response. * - * @since 6.8.0 + * @since 7.0.0 * * @param array $wp_response WordPress HTTP API response array. * @return ResponseInterface PSR-7 response. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php index c9a8f75b9e934..3f6669d84297c 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr17-factory.php @@ -4,17 +4,17 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriFactoryInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Combined PSR-17 factory for creating PSR-7 HTTP message objects. @@ -22,14 +22,14 @@ * Implements all four PSR-17 factory interfaces, delegating to the minimal * WP AI Client PSR-7 implementations. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR17_Factory implements RequestFactoryInterface, ResponseFactoryInterface, StreamFactoryInterface, UriFactoryInterface { /** * Creates a new request. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method The HTTP method associated with the request. * @param UriInterface|string $uri The URI associated with the request. @@ -42,7 +42,7 @@ public function createRequest( string $method, $uri ): RequestInterface { /** * Creates a new response. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $code HTTP status code. Defaults to 200. * @param string $reasonPhrase Reason phrase to associate with status code. @@ -55,7 +55,7 @@ public function createResponse( int $code = 200, string $reasonPhrase = '' ): Re /** * Creates a new stream from a string. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $content String content with which to populate the stream. * @return StreamInterface @@ -67,7 +67,7 @@ public function createStream( string $content = '' ): StreamInterface { /** * Creates a stream from an existing file. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $filename Filename or stream URI to use as basis of stream. * @param string $mode Mode with which to open the underlying filename/stream. @@ -86,7 +86,7 @@ public function createStreamFromFile( string $filename, string $mode = 'r' ): St /** * Creates a new stream from an existing resource. * - * @since 6.8.0 + * @since 7.0.0 * * @param resource $resource PHP resource to use as basis of stream. * @return StreamInterface @@ -104,7 +104,7 @@ public function createStreamFromResource( $resource ): StreamInterface { /** * Creates a new URI. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $uri The URI string. * @return UriInterface diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php index 62ca326f67dba..616a394f397ff 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php @@ -4,12 +4,12 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Minimal PSR-7 HTTP request implementation. @@ -17,14 +17,14 @@ * Immutable value object representing an outgoing HTTP request for the AI Client * HTTP transport layer. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * HTTP method. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $method; @@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Request URI. * - * @since 6.8.0 + * @since 7.0.0 * @var UriInterface */ private $uri; @@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $protocol_version = '1.1'; @@ -50,7 +50,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { * * Each value is an array with 'name' (original case) and 'values' (list of strings). * - * @since 6.8.0 + * @since 7.0.0 * @var array}> */ private $headers = array(); @@ -58,7 +58,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Request body. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamInterface */ private $body; @@ -66,7 +66,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Explicit request target, if set. * - * @since 6.8.0 + * @since 7.0.0 * @var string|null */ private $request_target; @@ -74,7 +74,7 @@ class WP_AI_Client_PSR7_Request implements RequestInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method HTTP method. * @param string|UriInterface $uri Request URI. @@ -93,7 +93,7 @@ public function __construct( string $method, $uri ) { /** * Retrieves the HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @return string HTTP protocol version. */ @@ -104,7 +104,7 @@ public function getProtocolVersion(): string { /** * Returns an instance with the specified HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $version HTTP protocol version. * @return static @@ -119,7 +119,7 @@ public function withProtocolVersion( string $version ): self { /** * Retrieves all message header values. * - * @since 6.8.0 + * @since 7.0.0 * * @return string[][] Associative array of headers. */ @@ -136,7 +136,7 @@ public function getHeaders(): array { /** * Checks if a header exists by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return bool @@ -148,7 +148,7 @@ public function hasHeader( string $name ): bool { /** * Retrieves a message header value by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string[] Header values. @@ -166,7 +166,7 @@ public function getHeader( string $name ): array { /** * Retrieves a comma-separated string of the values for a single header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string @@ -178,7 +178,7 @@ public function getHeaderLine( string $name ): string { /** * Returns an instance with the provided value replacing the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @param string|string[] $value Header value(s). @@ -194,7 +194,7 @@ public function withHeader( string $name, $value ): self { /** * Returns an instance with the specified header appended with the given value. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to add. * @param string|string[] $value Header value(s). @@ -223,7 +223,7 @@ public function withAddedHeader( string $name, $value ): self { /** * Returns an instance without the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to remove. * @return static @@ -238,7 +238,7 @@ public function withoutHeader( string $name ): self { /** * Gets the body of the message. * - * @since 6.8.0 + * @since 7.0.0 * * @return StreamInterface */ @@ -249,7 +249,7 @@ public function getBody(): StreamInterface { /** * Returns an instance with the specified message body. * - * @since 6.8.0 + * @since 7.0.0 * * @param StreamInterface $body Body. * @return static @@ -264,7 +264,7 @@ public function withBody( StreamInterface $body ): self { /** * Retrieves the message's request target. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -291,7 +291,7 @@ public function getRequestTarget(): string { /** * Returns an instance with the specific request-target. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $requestTarget Request target. * @return static @@ -306,7 +306,7 @@ public function withRequestTarget( string $requestTarget ): self { /** * Retrieves the HTTP method of the request. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -317,7 +317,7 @@ public function getMethod(): string { /** * Returns an instance with the provided HTTP method. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $method Case-sensitive method. * @return static @@ -332,7 +332,7 @@ public function withMethod( string $method ): self { /** * Retrieves the URI instance. * - * @since 6.8.0 + * @since 7.0.0 * * @return UriInterface */ @@ -343,7 +343,7 @@ public function getUri(): UriInterface { /** * Returns an instance with the provided URI. * - * @since 6.8.0 + * @since 7.0.0 * * @param UriInterface $uri New request URI to use. * @param bool $preserveHost Preserve the original state of the Host header. @@ -369,7 +369,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self { /** * Sets a header internally (mutating, for use in constructor and clone methods). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Header name. * @param string|string[] $value Header value(s). diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php index fe84a7dc5dfd1..35c3bba303759 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php @@ -4,11 +4,11 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; /** * Minimal PSR-7 HTTP response implementation. @@ -16,14 +16,14 @@ * Immutable value object representing an incoming HTTP response for the AI Client * HTTP transport layer. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * HTTP status code. * - * @since 6.8.0 + * @since 7.0.0 * @var int */ private $status_code; @@ -31,7 +31,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Reason phrase associated with the status code. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $reason_phrase; @@ -39,7 +39,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $protocol_version = '1.1'; @@ -49,7 +49,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { * * Each value is an array with 'name' (original case) and 'values' (list of strings). * - * @since 6.8.0 + * @since 7.0.0 * @var array}> */ private $headers = array(); @@ -57,7 +57,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Response body. * - * @since 6.8.0 + * @since 7.0.0 * @var StreamInterface */ private $body; @@ -65,7 +65,7 @@ class WP_AI_Client_PSR7_Response implements ResponseInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $status_code HTTP status code. * @param string $reason_phrase Reason phrase to associate with the status code. @@ -79,7 +79,7 @@ public function __construct( int $status_code = 200, string $reason_phrase = '' /** * Gets the response status code. * - * @since 6.8.0 + * @since 7.0.0 * * @return int Status code. */ @@ -90,7 +90,7 @@ public function getStatusCode(): int { /** * Returns an instance with the specified status code and reason phrase. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $code The 3-digit integer result code to set. * @param string $reasonPhrase The reason phrase to use. @@ -107,7 +107,7 @@ public function withStatus( int $code, string $reasonPhrase = '' ): self { /** * Gets the response reason phrase associated with the status code. * - * @since 6.8.0 + * @since 7.0.0 * * @return string Reason phrase. */ @@ -118,7 +118,7 @@ public function getReasonPhrase(): string { /** * Retrieves the HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @return string HTTP protocol version. */ @@ -129,7 +129,7 @@ public function getProtocolVersion(): string { /** * Returns an instance with the specified HTTP protocol version. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $version HTTP protocol version. * @return static @@ -144,7 +144,7 @@ public function withProtocolVersion( string $version ): self { /** * Retrieves all message header values. * - * @since 6.8.0 + * @since 7.0.0 * * @return string[][] Associative array of headers. */ @@ -161,7 +161,7 @@ public function getHeaders(): array { /** * Checks if a header exists by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return bool @@ -173,7 +173,7 @@ public function hasHeader( string $name ): bool { /** * Retrieves a message header value by the given case-insensitive name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string[] Header values. @@ -191,7 +191,7 @@ public function getHeader( string $name ): array { /** * Retrieves a comma-separated string of the values for a single header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @return string @@ -203,7 +203,7 @@ public function getHeaderLine( string $name ): string { /** * Returns an instance with the provided value replacing the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name. * @param string|string[] $value Header value(s). @@ -223,7 +223,7 @@ public function withHeader( string $name, $value ): self { /** * Returns an instance with the specified header appended with the given value. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to add. * @param string|string[] $value Header value(s). @@ -252,7 +252,7 @@ public function withAddedHeader( string $name, $value ): self { /** * Returns an instance without the specified header. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name Case-insensitive header field name to remove. * @return static @@ -267,7 +267,7 @@ public function withoutHeader( string $name ): self { /** * Gets the body of the message. * - * @since 6.8.0 + * @since 7.0.0 * * @return StreamInterface */ @@ -278,7 +278,7 @@ public function getBody(): StreamInterface { /** * Returns an instance with the specified message body. * - * @since 6.8.0 + * @since 7.0.0 * * @param StreamInterface $body Body. * @return static diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php index 273b04a8fb669..5ba6395e45754 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-stream.php @@ -4,10 +4,10 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; /** * Minimal string-backed PSR-7 stream implementation. @@ -15,14 +15,14 @@ * Provides the StreamInterface methods needed by the AI Client HTTP transport * layer without requiring PHP stream resources. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * The string content of the stream. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $content; @@ -30,7 +30,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * Current read/write offset position. * - * @since 6.8.0 + * @since 7.0.0 * @var int */ private $offset = 0; @@ -38,7 +38,7 @@ class WP_AI_Client_PSR7_Stream implements StreamInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $content Initial content for the stream. */ @@ -49,7 +49,7 @@ public function __construct( string $content = '' ) { /** * Reads all data from the stream into a string. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -60,7 +60,7 @@ public function __toString(): string { /** * Closes the stream. No-op for string-backed streams. * - * @since 6.8.0 + * @since 7.0.0 */ public function close(): void { // No-op. @@ -69,7 +69,7 @@ public function close(): void { /** * Separates any underlying resources from the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return resource|null Always null for string-backed streams. */ @@ -80,7 +80,7 @@ public function detach() { /** * Gets the size of the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return int|null The size in bytes. */ @@ -91,7 +91,7 @@ public function getSize(): ?int { /** * Returns the current position of the read/write pointer. * - * @since 6.8.0 + * @since 7.0.0 * * @return int Position of the pointer. */ @@ -102,7 +102,7 @@ public function tell(): int { /** * Returns true if the stream is at the end. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool */ @@ -113,7 +113,7 @@ public function eof(): bool { /** * Returns whether the stream is seekable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -124,7 +124,7 @@ public function isSeekable(): bool { /** * Seeks to a position in the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $offset Stream offset. * @param int $whence One of SEEK_SET, SEEK_CUR, or SEEK_END. @@ -152,7 +152,7 @@ public function seek( int $offset, int $whence = SEEK_SET ): void { /** * Seeks to the beginning of the stream. * - * @since 6.8.0 + * @since 7.0.0 */ public function rewind(): void { $this->offset = 0; @@ -161,7 +161,7 @@ public function rewind(): void { /** * Returns whether the stream is writable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -172,7 +172,7 @@ public function isWritable(): bool { /** * Writes data to the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $string The string to write. * @return int Number of bytes written. @@ -188,7 +188,7 @@ public function write( string $string ): int { /** * Returns whether the stream is readable. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool Always true. */ @@ -199,7 +199,7 @@ public function isReadable(): bool { /** * Reads data from the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $length Number of bytes to read. * @return string Data read from the stream. @@ -214,7 +214,7 @@ public function read( int $length ): string { /** * Returns the remaining contents of the stream. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -228,7 +228,7 @@ public function getContents(): string { /** * Gets stream metadata. * - * @since 6.8.0 + * @since 7.0.0 * * @param string|null $key Specific metadata to retrieve. * @return array|mixed|null Returns null for specific keys, empty array otherwise. diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php index 8ea0cf4546b7a..58dfb364d469b 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-uri.php @@ -4,24 +4,24 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * Minimal PSR-7 URI implementation. * * Wraps PHP's parse_url() components into an immutable UriInterface value object. * - * @since 6.8.0 + * @since 7.0.0 */ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * Standard ports for HTTP and HTTPS. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static $default_ports = array( @@ -32,7 +32,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI scheme (e.g. "http", "https"). * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $scheme = ''; @@ -40,7 +40,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI user info (e.g. "user:password"). * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $user_info = ''; @@ -48,7 +48,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI host. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $host = ''; @@ -56,7 +56,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI port. * - * @since 6.8.0 + * @since 7.0.0 * @var int|null */ private $port; @@ -64,7 +64,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI path. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $path = ''; @@ -72,7 +72,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI query string. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $query = ''; @@ -80,7 +80,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * URI fragment. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ private $fragment = ''; @@ -88,7 +88,7 @@ class WP_AI_Client_PSR7_Uri implements UriInterface { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $uri URI string to parse. */ @@ -118,7 +118,7 @@ public function __construct( string $uri = '' ) { /** * Retrieves the scheme component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI scheme. */ @@ -129,7 +129,7 @@ public function getScheme(): string { /** * Retrieves the authority component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI authority, in "[user-info@]host[:port]" format. */ @@ -154,7 +154,7 @@ public function getAuthority(): string { /** * Retrieves the user information component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI user information. */ @@ -165,7 +165,7 @@ public function getUserInfo(): string { /** * Retrieves the host component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI host. */ @@ -176,7 +176,7 @@ public function getHost(): string { /** * Retrieves the port component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return int|null The URI port, or null if standard or not set. */ @@ -191,7 +191,7 @@ public function getPort(): ?int { /** * Retrieves the path component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI path. */ @@ -202,7 +202,7 @@ public function getPath(): string { /** * Retrieves the query string of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI query string. */ @@ -213,7 +213,7 @@ public function getQuery(): string { /** * Retrieves the fragment component of the URI. * - * @since 6.8.0 + * @since 7.0.0 * * @return string The URI fragment. */ @@ -224,7 +224,7 @@ public function getFragment(): string { /** * Returns an instance with the specified scheme. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $scheme The scheme to use with the new instance. * @return static A new instance with the specified scheme. @@ -239,7 +239,7 @@ public function withScheme( string $scheme ): UriInterface { /** * Returns an instance with the specified user information. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $user The user name to use for authority. * @param string|null $password The password associated with $user. @@ -259,7 +259,7 @@ public function withUserInfo( string $user, ?string $password = null ): UriInter /** * Returns an instance with the specified host. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $host The hostname to use with the new instance. * @return static A new instance with the specified host. @@ -274,7 +274,7 @@ public function withHost( string $host ): UriInterface { /** * Returns an instance with the specified port. * - * @since 6.8.0 + * @since 7.0.0 * * @param int|null $port The port to use with the new instance. * @return static A new instance with the specified port. @@ -289,7 +289,7 @@ public function withPort( ?int $port ): UriInterface { /** * Returns an instance with the specified path. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $path The path to use with the new instance. * @return static A new instance with the specified path. @@ -304,7 +304,7 @@ public function withPath( string $path ): UriInterface { /** * Returns an instance with the specified query string. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $query The query string to use with the new instance. * @return static A new instance with the specified query string. @@ -319,7 +319,7 @@ public function withQuery( string $query ): UriInterface { /** * Returns an instance with the specified URI fragment. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $fragment The fragment to use with the new instance. * @return static A new instance with the specified fragment. @@ -334,7 +334,7 @@ public function withFragment( string $fragment ): UriInterface { /** * Returns the string representation as a URI reference. * - * @since 6.8.0 + * @since 7.0.0 * * @return string */ @@ -374,7 +374,7 @@ public function __toString(): string { /** * Checks whether the current port is the standard port for the scheme. * - * @since 6.8.0 + * @since 7.0.0 * * @return bool True if port is the standard port for the current scheme. */ diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index 999adeb6e9f90..2a5c2ef53b911 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ use WordPress\AiClient\Builders\PromptBuilder; @@ -38,7 +38,7 @@ * calls will be no-ops that just return the same error state instance. Only * when a generating method is called, the WP_Error will be returned. * - * @since 6.8.0 + * @since 7.0.0 * * @method self with_text(string $text) Adds text to the current message. * @method self with_file($file, ?string $mimeType = null) Adds a file to the current message. @@ -94,7 +94,7 @@ class WP_AI_Client_Prompt_Builder { /** * Wrapped prompt builder instance from the PHP AI Client SDK. * - * @since 6.8.0 + * @since 7.0.0 * @var PromptBuilder */ private PromptBuilder $builder; @@ -102,7 +102,7 @@ class WP_AI_Client_Prompt_Builder { /** * WordPress error instance, if any error occurred during method calls. * - * @since 6.8.0 + * @since 7.0.0 * @var WP_Error|null */ private ?WP_Error $error = null; @@ -112,7 +112,7 @@ class WP_AI_Client_Prompt_Builder { * * Structured as a map for faster lookups. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static array $generating_methods = array( @@ -136,7 +136,7 @@ class WP_AI_Client_Prompt_Builder { * * Structured as a map for faster lookups. * - * @since 6.8.0 + * @since 7.0.0 * @var array */ private static array $support_check_methods = array( @@ -153,7 +153,7 @@ class WP_AI_Client_Prompt_Builder { /** * Constructor. * - * @since 6.8.0 + * @since 7.0.0 * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param mixed $prompt Optional initial prompt content. @@ -164,7 +164,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) { /** * Filters the default request timeout in seconds for AI Client HTTP requests. * - * @since 6.8.0 + * @since 7.0.0 * * @param int $default_timeout The default timeout in seconds. */ @@ -185,7 +185,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) { * Converts each WP_Ability to a FunctionDeclaration using the wpab__ prefix * naming convention and passes them to the underlying prompt builder. * - * @since 6.8.0 + * @since 7.0.0 * * @param WP_Ability|string ...$abilities The abilities to register, either as WP_Ability objects or ability name strings. * @return self The current instance for method chaining. @@ -226,7 +226,7 @@ public function using_abilities( ...$abilities ): self { * any exceptions thrown, stores them, and returns a WP_Error when a terminate method * is called. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name in snake_case. * @param array $arguments The method arguments. @@ -252,7 +252,7 @@ public function __call( string $name, array $arguments ) { /** * Filters whether to prevent the prompt from being executed. * - * @since 6.8.0 + * @since 7.0.0 * * @param bool $prevent Whether to prevent the prompt. Default false. * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). @@ -310,7 +310,7 @@ public function __call( string $name, array $arguments ) { /** * Checks if a method name is a support check method (is_supported*). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name. * @return bool True if the method is a support check method, false otherwise. @@ -322,7 +322,7 @@ private static function is_support_check_method( string $name ): bool { /** * Checks if a method name is a generating method (generate_*, convert_text_to_speech*). * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name. * @return bool True if the method is a generating method, false otherwise. @@ -334,7 +334,7 @@ private static function is_generating_method( string $name ): bool { /** * Retrieves a callable for a given PHP AI Client SDK prompt builder method name. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $name The method name in snake_case. * @return callable The callable for the specified method. @@ -360,7 +360,7 @@ protected function get_builder_callable( string $name ): callable { /** * Converts snake_case to camelCase. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $snake_case The snake_case string. * @return string The camelCase string. diff --git a/src/wp-includes/php-ai-client/autoload.php b/src/wp-includes/php-ai-client/autoload.php index 7cd81ed038277..b4305ff4c7ed8 100644 --- a/src/wp-includes/php-ai-client/autoload.php +++ b/src/wp-includes/php-ai-client/autoload.php @@ -16,21 +16,13 @@ spl_autoload_register( static function ( $class_name ) { // Namespace prefix for the AI client. - $client_prefix = 'WordPress\\AiClient\\'; + $client_prefix = 'WordPress\\AiClient\\'; $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) - // Namespace prefix for scoped dependencies. + // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.). $scoped_prefix = 'WordPress\\AiClientDependencies\\'; $scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' ) - // PSR interface namespaces (not scoped, kept global). - $psr_prefixes = array( - 'Psr\\Http\\Client\\' => 16, - 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 20, - 'Psr\\SimpleCache\\' => 16, - ); - $base_dir = __DIR__; // 1. WordPress\AiClient\* → src/ @@ -52,17 +44,5 @@ static function ( $class_name ) { } return; } - - // 3. Psr\* interfaces → third-party/Psr/... - foreach ( $psr_prefixes as $prefix => $prefix_len ) { - if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { - $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. - $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; - if ( file_exists( $file ) ) { - require $file; - } - return; - } - } } ); diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index fb8e1ced1f4d2..f851cfe82d5dc 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -3,8 +3,8 @@ declare (strict_types=1); namespace WordPress\AiClient; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\SimpleCache\CacheInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php index d135df56c97fe..6821b99280bd3 100644 --- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -3,7 +3,7 @@ declare (strict_types=1); namespace WordPress\AiClient\Builders; -use Psr\EventDispatcher\EventDispatcherInterface; +use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php index dddfb952a2449..b6a088725f3d5 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Contracts/ClientWithOptionsInterface.php @@ -3,8 +3,8 @@ declare (strict_types=1); namespace WordPress\AiClient\Providers\Http\Contracts; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; /** * Interface for HTTP clients that support per-request transport options. diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php index 211daf5ec7acd..8d62f01746632 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/DTO/Request.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\DTO; use JsonException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php index 8b4977eb14738..1b26ac2c60f0b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/NetworkException.php @@ -3,7 +3,7 @@ declare (strict_types=1); namespace WordPress\AiClient\Providers\Http\Exception; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Request; /** diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php index 0dc8e56c82a18..dd6cc3e9e4c4b 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/HttpTransporter.php @@ -5,11 +5,11 @@ use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery; use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; @@ -75,9 +75,9 @@ public function send(Request $request, ?RequestOptions $options = null): Respons } else { $psr7Response = $this->client->sendRequest($psr7Request); } - } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { + } catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); - } catch (\Psr\Http\Client\ClientExceptionInterface $e) { + } catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) { // Handle other PSR-18 client exceptions that are not network-related throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e); } diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php index f84213a167212..62193c03c9abc 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client; -use Psr\Http\Client\ClientExceptionInterface as PsrClientException; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface as PsrClientException; /** * Every HTTP Client related Exception must implement this interface. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php index 6e05303eaafc7..fabf0d0486a99 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; /** * Thrown when a response was received but the request itself failed. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php index ece5bdf587362..73bee0c013eea 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Thrown when the request cannot be completed because of network issues. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php index fe337b0a34675..dc0c0d60666d8 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; trait RequestAwareTrait { /** diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php index ec080724b889b..036e6182590ec 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php @@ -2,8 +2,8 @@ namespace WordPress\AiClientDependencies\Http\Client\Exception; -use Psr\Http\Client\RequestExceptionInterface as PsrRequestException; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\RequestExceptionInterface as PsrRequestException; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Exception for when a request failed, providing access to the failed request. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php index 4b45bdf90f554..2d7399c385b7e 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php @@ -3,7 +3,7 @@ namespace WordPress\AiClientDependencies\Http\Client; use WordPress\AiClientDependencies\Http\Promise\Promise; -use Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; /** * Sends a PSR-7 Request in an asynchronous way by returning a Promise. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php index 244b9ddb7dbc6..5ea57d8c7a735 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php @@ -2,7 +2,7 @@ namespace WordPress\AiClientDependencies\Http\Client; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * {@inheritdoc} * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php index 52a278e32c7f5..be344a4834401 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php @@ -4,7 +4,7 @@ use WordPress\AiClientDependencies\Http\Client\Exception; use WordPress\AiClientDependencies\Http\Promise\Promise; -use Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; final class HttpFulfilledPromise implements Promise { /** diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php index ed28ffc0b06a4..389eede1b5027 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php @@ -1,24 +1,24 @@ 'symfony/framework-bundle', 'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7', 'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6', 'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5', 'php-http/cakephp-adapter' => 'cakephp/cakephp', 'php-http/react-adapter' => 'react/event-loop', 'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1', 'php-http/artax-adapter' => 'amphp/artax:^3', 'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1', 'http-interop/http-factory-slim' => 'slim/slim:^3']; - private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['Psr\Http\Message\RequestFactoryInterface', 'Psr\Http\Message\ResponseFactoryInterface', 'Psr\Http\Message\ServerRequestFactoryInterface', 'Psr\Http\Message\StreamFactoryInterface', 'Psr\Http\Message\UploadedFileFactoryInterface', 'Psr\Http\Message\UriFactoryInterface']]; + private const INTERFACE_MAP = ['php-http/async-client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpAsyncClient'], 'php-http/client-implementation' => ['WordPress\AiClientDependencies\Http\Client\HttpClient'], 'psr/http-client-implementation' => ['WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface'], 'psr/http-factory-implementation' => ['WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface']]; public static function getSubscribedEvents(): array { return [ScriptEvents::PRE_AUTOLOAD_DUMP => 'preAutoloadDump', ScriptEvents::POST_UPDATE_CMD => 'postUpdate']; diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php index 561f76b0914b8..2f8880a7111df 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php @@ -2,18 +2,18 @@ namespace WordPress\AiClientDependencies\Http\Discovery; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UploadedFileInterface; -use Psr\Http\Message\UriFactoryInterface; -use Psr\Http\Message\UriInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriInterface; /** * A generic PSR-17 implementation. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php index d9e5f9cd42f27..5e22ab1dd03c0 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17FactoryDiscovery.php @@ -4,12 +4,12 @@ use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException; use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * Finds PSR-17 factories. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php index 83ed4ce970631..55de2592340f3 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php @@ -2,15 +2,15 @@ namespace WordPress\AiClientDependencies\Http\Discovery; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * A generic PSR-18 and PSR-17 implementation. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php index 9093e74df078b..ceca0e4a515b5 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18ClientDiscovery.php @@ -4,7 +4,7 @@ use WordPress\AiClientDependencies\Http\Discovery\Exception\DiscoveryFailedException; use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as RealNotFoundException; -use Psr\Http\Client\ClientInterface; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface; /** * Finds a PSR-18 HTTP Client. * diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php index 02b3fdbf8a5b8..e9c65c8220e93 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonClassesStrategy.php @@ -33,8 +33,8 @@ use WordPress\AiClientDependencies\Http\Message\UriFactory\SlimUriFactory; use WordPress\AiClientDependencies\Laminas\Diactoros\Request as DiactorosRequest; use WordPress\AiClientDependencies\Nyholm\Psr7\Factory\HttplugFactory as NyholmHttplugFactory; -use Psr\Http\Client\ClientInterface as Psr18Client; -use Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory; +use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface as Psr18Client; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory; use WordPress\AiClientDependencies\Slim\Http\Request as SlimRequest; use WordPress\AiClientDependencies\Symfony\Component\HttpClient\HttplugClient as SymfonyHttplug; use WordPress\AiClientDependencies\Symfony\Component\HttpClient\Psr18Client as SymfonyPsr18; diff --git a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php index 3e5227f6d56ce..7a310542c13c4 100644 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php +++ b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/CommonPsr17ClassesStrategy.php @@ -2,12 +2,12 @@ namespace WordPress\AiClientDependencies\Http\Discovery\Strategy; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface; +use WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface; /** * @internal * diff --git a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php index d522445fce250..4b85d3d500600 100644 --- a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php +++ b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/EventDispatcherInterface.php @@ -1,7 +1,7 @@ "$TARGET_DIR/autoload.php" << 'AUTOLOAD_PHP' * * @package WordPress * @subpackage AI - * @since 6.8.0 + * @since 7.0.0 */ // Load polyfills (each function is guarded by function_exists). @@ -219,21 +219,13 @@ require_once __DIR__ . '/src/polyfills.php'; spl_autoload_register( static function ( $class_name ) { // Namespace prefix for the AI client. - $client_prefix = 'WordPress\\AiClient\\'; - $client_prefix_len = 20; // strlen( 'WordPress\\AiClient\\' ) + $client_prefix = 'WordPress\\AiClient\\'; + $client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' ) - // Namespace prefix for scoped dependencies. + // Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.). $scoped_prefix = 'WordPress\\AiClientDependencies\\'; $scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' ) - // PSR interface namespaces (not scoped, kept global). - $psr_prefixes = array( - 'Psr\\Http\\Client\\' => 16, - 'Psr\\Http\\Message\\' => 17, - 'Psr\\EventDispatcher\\' => 21, - 'Psr\\SimpleCache\\' => 16, - ); - $base_dir = __DIR__; // 1. WordPress\AiClient\* → src/ @@ -255,18 +247,6 @@ spl_autoload_register( } return; } - - // 3. Psr\* interfaces → third-party/Psr/... - foreach ( $psr_prefixes as $prefix => $prefix_len ) { - if ( 0 === strncmp( $class_name, $prefix, $prefix_len ) ) { - $relative_class = substr( $class_name, 4 ); // Strip 'Psr\' prefix, keep sub-namespace. - $file = $base_dir . '/third-party/Psr/' . str_replace( '\\', '/', $relative_class ) . '.php'; - if ( file_exists( $file ) ) { - require $file; - } - return; - } - } } ); AUTOLOAD_PHP @@ -316,10 +296,14 @@ if [ -d "$TARGET_DIR/third-party/Http" ]; then fi fi -# Check that Psr interfaces are NOT scoped. +# Check that Psr interfaces are scoped. if [ -d "$TARGET_DIR/third-party/Psr" ]; then - UNSCOPED_PSR=$(grep -rL "namespace WordPress\\\\AiClientDependencies" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ') - echo " Found $UNSCOPED_PSR unscoped Psr\\* files." + SCOPED_PSR=$(grep -rl "namespace WordPress\\\\AiClientDependencies\\\\Psr" "$TARGET_DIR/third-party/Psr/" 2>/dev/null | wc -l | tr -d ' ') + if [ "$SCOPED_PSR" -eq 0 ]; then + echo "Warning: No scoped Psr\\* namespaces found in third-party/Psr/." + else + echo " Found $SCOPED_PSR scoped Psr\\* files." + fi fi if [ "$ERRORS" -gt 0 ]; then diff --git a/tools/php-ai-client/scoper.inc.php b/tools/php-ai-client/scoper.inc.php index cbe0428a9909b..f08a4ebaf4f41 100644 --- a/tools/php-ai-client/scoper.inc.php +++ b/tools/php-ai-client/scoper.inc.php @@ -2,11 +2,8 @@ /** * PHP-Scoper configuration for bundling php-ai-client dependencies. * - * Scopes Http\* namespaces (php-http packages) to WordPress\AiClientDependencies\Http\* - * to avoid conflicts with plugin-bundled versions. - * - * PSR interfaces (Psr\*) are excluded from scoping so that external HTTP - * implementations (Guzzle, Nyholm, etc.) remain type-compatible. + * Scopes all third-party namespaces (Http\*, Psr\*, etc.) to + * WordPress\AiClientDependencies\* to avoid conflicts with plugin-bundled versions. * * @package WordPress */ @@ -22,7 +19,7 @@ ->files() ->ignoreVCS( true ) ->notName( '/LICENSE|.*\\.md|.*\\.dist|Makefile/' ) - ->exclude( array( 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) ) + ->exclude( array( 'composer', 'doc', 'test', 'test_old', 'tests', 'Tests', 'vendor-bin' ) ) ->in( 'vendor' ), // Include the AI client source files so `use` statements referencing @@ -38,15 +35,6 @@ 'exclude-namespaces' => array( // The AI client's own namespace must not be scoped. 'WordPress\\AiClient', - - // PSR interfaces stay global for type compatibility with external implementations. - 'Psr\\Http\\Client', - 'Psr\\Http\\Message', - 'Psr\\EventDispatcher', - 'Psr\\SimpleCache', - - // Composer's own namespace. - 'Composer', ), 'exclude-files' => array(), From 8a9d2c6cabc75836f968a10f6c285594c2604d24 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 17:56:08 -0700 Subject: [PATCH 028/147] feat: adds wp_ai_client_prompt function --- src/wp-includes/ai-client.php | 22 +++++ src/wp-settings.php | 1 + .../tests/ai-client/wpAiClientPrompt.php | 92 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/wp-includes/ai-client.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientPrompt.php diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php new file mode 100644 index 0000000000000..1ceccbbb35d77 --- /dev/null +++ b/src/wp-includes/ai-client.php @@ -0,0 +1,22 @@ +assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $builder ); + } + + /** + * Test that wp_ai_client_prompt() wraps a PromptBuilder internally. + * + * @ticket TBD + */ + public function test_wraps_sdk_prompt_builder() { + $builder = wp_ai_client_prompt(); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $property = $reflection->getProperty( 'builder' ); + $property->setAccessible( true ); + + $this->assertInstanceOf( PromptBuilder::class, $property->getValue( $builder ) ); + } + + /** + * Test that wp_ai_client_prompt() passes prompt content to the builder. + * + * @ticket TBD + */ + public function test_passes_prompt_content() { + $builder = wp_ai_client_prompt( 'Hello, AI!' ); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped = $builder_property->getValue( $builder ); + + $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) ); + $messages_property = $wrapped_reflection->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + $messages = $messages_property->getValue( $wrapped ); + + $this->assertNotEmpty( $messages, 'Prompt content should produce at least one message.' ); + } + + /** + * Test that wp_ai_client_prompt() without arguments creates builder with no messages. + * + * @ticket TBD + */ + public function test_no_prompt_creates_empty_builder() { + $builder = wp_ai_client_prompt(); + + $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); + $builder_property = $reflection->getProperty( 'builder' ); + $builder_property->setAccessible( true ); + $wrapped = $builder_property->getValue( $builder ); + + $wrapped_reflection = new ReflectionClass( get_class( $wrapped ) ); + $messages_property = $wrapped_reflection->getProperty( 'messages' ); + $messages_property->setAccessible( true ); + $messages = $messages_property->getValue( $wrapped ); + + $this->assertEmpty( $messages, 'No prompt content should produce no messages.' ); + } + + /** + * Test that successive calls return independent builder instances. + * + * @ticket TBD + */ + public function test_returns_independent_instances() { + $builder1 = wp_ai_client_prompt( 'First' ); + $builder2 = wp_ai_client_prompt( 'Second' ); + + $this->assertNotSame( $builder1, $builder2 ); + } +} From 56c68731f6d45028bfecb1741470a775e33c1c0f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 6 Feb 2026 18:01:15 -0700 Subject: [PATCH 029/147] refactor: corrects formatting issues --- .../class-wp-ai-client-psr7-request.php | 2 +- .../class-wp-ai-client-psr7-response.php | 6 +++--- .../class-wp-ai-client-prompt-builder.php | 14 +++++++------- .../tests/ai-client/wpAiClientEventDispatcher.php | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php index 616a394f397ff..1afc8ba87e974 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-request.php @@ -375,7 +375,7 @@ public function withUri( UriInterface $uri, bool $preserveHost = false ): self { * @param string|string[] $value Header value(s). */ private function set_header_internal( string $name, $value ): void { - $normalized = strtolower( $name ); + $normalized = strtolower( $name ); $this->headers[ $normalized ] = array( 'name' => $name, 'values' => is_array( $value ) ? $value : array( $value ), diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php index 35c3bba303759..eb84d2edd73ba 100644 --- a/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-psr7-response.php @@ -210,9 +210,9 @@ public function getHeaderLine( string $name ): string { * @return static */ public function withHeader( string $name, $value ): self { - $new = clone $this; - $normalized = strtolower( $name ); - $new->headers[ $normalized ] = array( + $new = clone $this; + $normalized = strtolower( $name ); + $new->headers[ $normalized ] = array( 'name' => $name, 'values' => is_array( $value ) ? $value : array( $value ), ); diff --git a/src/wp-includes/class-wp-ai-client-prompt-builder.php b/src/wp-includes/class-wp-ai-client-prompt-builder.php index 2a5c2ef53b911..e4a7c656ffdda 100644 --- a/src/wp-includes/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/class-wp-ai-client-prompt-builder.php @@ -140,14 +140,14 @@ class WP_AI_Client_Prompt_Builder { * @var array */ private static array $support_check_methods = array( - 'is_supported' => true, - 'is_supported_for_text_generation' => true, - 'is_supported_for_image_generation' => true, + 'is_supported' => true, + 'is_supported_for_text_generation' => true, + 'is_supported_for_image_generation' => true, 'is_supported_for_text_to_speech_conversion' => true, - 'is_supported_for_video_generation' => true, - 'is_supported_for_speech_generation' => true, - 'is_supported_for_music_generation' => true, - 'is_supported_for_embedding_generation' => true, + 'is_supported_for_video_generation' => true, + 'is_supported_for_speech_generation' => true, + 'is_supported_for_music_generation' => true, + 'is_supported_for_embedding_generation' => true, ); /** diff --git a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php index 6e7c7aac40953..3cd621f09bf2c 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php +++ b/tests/phpunit/tests/ai-client/wpAiClientEventDispatcher.php @@ -19,7 +19,7 @@ public function test_dispatch_fires_action_hook() { $dispatcher = new WP_AI_Client_Event_Dispatcher(); $event = new WP_AI_Client_Mock_Event(); - $hook_fired = false; + $hook_fired = false; $fired_event = null; add_action( @@ -43,8 +43,8 @@ function ( $e ) use ( &$hook_fired, &$fired_event ) { * @ticket TBD */ public function test_dispatch_returns_event_without_listeners() { - $dispatcher = new WP_AI_Client_Event_Dispatcher(); - $event = new stdClass(); + $dispatcher = new WP_AI_Client_Event_Dispatcher(); + $event = new stdClass(); $event->test_value = 'original'; $result = $dispatcher->dispatch( $event ); From a5bd7925251365226d5621306a5ec19ee659c3c4 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 11 Feb 2026 09:34:22 -0700 Subject: [PATCH 030/147] refactor: adds and runs third-party tree-shaking --- .../class-wp-ai-client-cache.php | 209 ++++++++++++ .../third-party/Http/Client/Exception.php | 13 - .../Http/Client/Exception/HttpException.php | 46 --- .../Client/Exception/NetworkException.php | 25 -- .../Client/Exception/RequestAwareTrait.php | 20 -- .../Client/Exception/RequestException.php | 26 -- .../Client/Exception/TransferException.php | 13 - .../Http/Client/HttpAsyncClient.php | 24 -- .../third-party/Http/Client/HttpClient.php | 16 - .../Client/Promise/HttpFulfilledPromise.php | 39 --- .../Client/Promise/HttpRejectedPromise.php | 42 --- .../Http/Discovery/Composer/Plugin.php | 319 ------------------ .../Discovery/HttpAsyncClientDiscovery.php | 30 -- .../Http/Discovery/HttpClientDiscovery.php | 32 -- .../Discovery/MessageFactoryDiscovery.php | 32 -- .../Http/Discovery/NotFoundException.php | 15 - .../Http/Discovery/Psr17Factory.php | 241 ------------- .../Http/Discovery/Psr18Client.php | 40 --- .../Discovery/Strategy/MockClientStrategy.php | 22 -- .../Http/Discovery/StreamFactoryDiscovery.php | 32 -- .../Http/Discovery/UriFactoryDiscovery.php | 32 -- .../Http/Promise/FulfilledPromise.php | 45 --- .../third-party/Http/Promise/Promise.php | 64 ---- .../Http/Promise/RejectedPromise.php | 42 --- .../ListenerProviderInterface.php | 19 -- .../StoppableEventInterface.php | 26 -- .../Message/ServerRequestFactoryInterface.php | 24 -- .../Http/Message/ServerRequestInterface.php | 249 -------------- .../Message/UploadedFileFactoryInterface.php | 28 -- .../Http/Message/UploadedFileInterface.php | 118 ------- .../Psr/SimpleCache/CacheException.php | 10 - .../SimpleCache/InvalidArgumentException.php | 13 - src/wp-settings.php | 2 + .../tests/ai-client/wpAiClientCache.php | 175 ++++++++++ tools/php-ai-client/installer.sh | 45 +++ 35 files changed, 431 insertions(+), 1697 deletions(-) create mode 100644 src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpFulfilledPromise.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/ListenerProviderInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/StoppableEventInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestFactoryInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/ServerRequestInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileInterface.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/CacheException.php delete mode 100644 src/wp-includes/php-ai-client/third-party/Psr/SimpleCache/InvalidArgumentException.php create mode 100644 tests/phpunit/tests/ai-client/wpAiClientCache.php diff --git a/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php b/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php new file mode 100644 index 0000000000000..ca19cb6de77bf --- /dev/null +++ b/src/wp-includes/ai-client-utils/class-wp-ai-client-cache.php @@ -0,0 +1,209 @@ +ttl_to_seconds( $ttl ); + + return wp_cache_set( $key, $value, self::CACHE_GROUP, $expire ); + } + + /** + * Delete an item from the cache by its unique key. + * + * @since 7.0.0 + * + * @param string $key The unique cache key of the item to delete. + * @return bool True if the item was successfully removed. False if there was an error. + */ + public function delete( $key ): bool { + return wp_cache_delete( $key, self::CACHE_GROUP ); + } + + /** + * Wipes clean the entire cache's keys. + * + * This method only clears the cache group used by this adapter. If the underlying + * cache implementation does not support group flushing, this method returns false. + * + * @since 7.0.0 + * + * @return bool True on success and false on failure. + */ + public function clear(): bool { + if ( ! function_exists( 'wp_cache_supports' ) || ! wp_cache_supports( 'flush_group' ) ) { + return false; + } + + return wp_cache_flush_group( self::CACHE_GROUP ); + } + + /** + * Obtains multiple cache items by their unique keys. + * + * @since 7.0.0 + * + * @param iterable $keys A list of keys that can be obtained in a single operation. + * @param mixed $default_value Default value to return for keys that do not exist. + * @return array A list of key => value pairs. + */ + public function getMultiple( $keys, $default_value = null ) { + /** + * Keys array. + * + * @var array $keys_array + */ + $keys_array = $this->iterable_to_array( $keys ); + $values = wp_cache_get_multiple( $keys_array, self::CACHE_GROUP ); + $result = array(); + + foreach ( $keys_array as $key ) { + $result[ $key ] = isset( $values[ $key ] ) && false !== $values[ $key ] ? $values[ $key ] : $default_value; + } + + return $result; + } + + /** + * Persists a set of key => value pairs in the cache, with an optional TTL. + * + * @since 7.0.0 + * + * @param iterable $values A list of key => value pairs for a multiple-set operation. + * @param null|int|DateInterval $ttl Optional. The TTL value of this item. + * @return bool True on success and false on failure. + */ + public function setMultiple( $values, $ttl = null ): bool { + $values_array = $this->iterable_to_array( $values ); + $expire = $this->ttl_to_seconds( $ttl ); + $results = wp_cache_set_multiple( $values_array, self::CACHE_GROUP, $expire ); + + // Return true only if all operations succeeded. + return ! in_array( false, $results, true ); + } + + /** + * Deletes multiple cache items in a single operation. + * + * @since 7.0.0 + * + * @param iterable $keys A list of string-based keys to be deleted. + * @return bool True if the items were successfully removed. False if there was an error. + */ + public function deleteMultiple( $keys ): bool { + $keys_array = $this->iterable_to_array( $keys ); + $results = wp_cache_delete_multiple( $keys_array, self::CACHE_GROUP ); + + // Return true only if all operations succeeded. + return ! in_array( false, $results, true ); + } + + /** + * Determines whether an item is present in the cache. + * + * @since 7.0.0 + * + * @param string $key The cache item key. + * @return bool True if the item exists in the cache, false otherwise. + */ + public function has( $key ): bool { + $found = false; + wp_cache_get( $key, self::CACHE_GROUP, false, $found ); + + return (bool) $found; + } + + /** + * Converts a PSR-16 TTL value to seconds for WordPress cache functions. + * + * @since 7.0.0 + * + * @param null|int|DateInterval $ttl The TTL value. + * @return int The TTL in seconds, or 0 for no expiration. + */ + private function ttl_to_seconds( $ttl ): int { + if ( null === $ttl ) { + return 0; + } + + if ( $ttl instanceof DateInterval ) { + $now = new DateTime(); + $end = ( clone $now )->add( $ttl ); + + return $end->getTimestamp() - $now->getTimestamp(); + } + + return max( 0, (int) $ttl ); + } + + /** + * Converts an iterable to an array. + * + * @since 7.0.0 + * + * @param iterable $items The iterable to convert. + * @return array The array. + */ + private function iterable_to_array( $items ): array { + if ( is_array( $items ) ) { + return $items; + } + + return iterator_to_array( $items ); + } +} diff --git a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php b/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php deleted file mode 100644 index 62193c03c9abc..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ -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 deleted file mode 100644 index fabf0d0486a99..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/HttpException.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ -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 deleted file mode 100644 index 73bee0c013eea..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/NetworkException.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -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 deleted file mode 100644 index dc0c0d60666d8..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestAwareTrait.php +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 036e6182590ec..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/RequestException.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -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 deleted file mode 100644 index 7caf710ef27c3..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Exception/TransferException.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ -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 deleted file mode 100644 index 2d7399c385b7e..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpAsyncClient.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -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 deleted file mode 100644 index 5ea57d8c7a735..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/HttpClient.php +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 5541415fdf166..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Client/Promise/HttpRejectedPromise.php +++ /dev/null @@ -1,42 +0,0 @@ -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/Composer/Plugin.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php deleted file mode 100644 index 389eede1b5027..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Composer/Plugin.php +++ /dev/null @@ -1,319 +0,0 @@ - - * - * @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' => ['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']; - } - 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/HttpAsyncClientDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php deleted file mode 100644 index 21b95eb2663fb..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpAsyncClientDiscovery.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -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 deleted file mode 100644 index fdfa0ec26edef..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/HttpClientDiscovery.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * @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 deleted file mode 100644 index 782b61367b00e..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/MessageFactoryDiscovery.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * @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 deleted file mode 100644 index 75a7c02f4a74f..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/NotFoundException.php +++ /dev/null @@ -1,15 +0,0 @@ - - * - * @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 deleted file mode 100644 index 2f8880a7111df..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr17Factory.php +++ /dev/null @@ -1,241 +0,0 @@ - - * 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/Psr18Client.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php deleted file mode 100644 index 55de2592340f3..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Psr18Client.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -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/Strategy/MockClientStrategy.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php deleted file mode 100644 index 3c05c3dce8db2..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/Strategy/MockClientStrategy.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ -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/StreamFactoryDiscovery.php b/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php deleted file mode 100644 index 770dd80b4ae80..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/StreamFactoryDiscovery.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * @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 deleted file mode 100644 index 8847fa4942c4d..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Discovery/UriFactoryDiscovery.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * @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 deleted file mode 100644 index 663b091a4e57a..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Promise/FulfilledPromise.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -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 deleted file mode 100644 index 8c3dcb452300a..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Promise/Promise.php +++ /dev/null @@ -1,64 +0,0 @@ - - * @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 deleted file mode 100644 index f1d8e2f9a173c..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Http/Promise/RejectedPromise.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ -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/ListenerProviderInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/ListenerProviderInterface.php deleted file mode 100644 index 3d2f2eed4c9e1..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Psr/EventDispatcher/ListenerProviderInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -getQuery()` - * or from the `QUERY_STRING` server param. - * - * @return array - */ - public function getQueryParams(): array; - /** - * Return an instance with the specified query string arguments. - * - * These values SHOULD remain immutable over the course of the incoming - * request. They MAY be injected during instantiation, such as from PHP's - * $_GET superglobal, or MAY be derived from some other value such as the - * URI. In cases where the arguments are parsed from the URI, the data - * MUST be compatible with what PHP's parse_str() would return for - * purposes of how duplicate query parameters are handled, and how nested - * sets are handled. - * - * Setting query string arguments MUST NOT change the URI stored by the - * request, nor the values in the server params. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated query string arguments. - * - * @param array $query Array of query string arguments, typically from - * $_GET. - * @return static - */ - public function withQueryParams(array $query): ServerRequestInterface; - /** - * Retrieve normalized file upload data. - * - * This method returns upload metadata in a normalized tree, with each leaf - * an instance of Psr\Http\Message\UploadedFileInterface. - * - * These values MAY be prepared from $_FILES or the message body during - * instantiation, or MAY be injected via withUploadedFiles(). - * - * @return array An array tree of UploadedFileInterface instances; an empty - * array MUST be returned if no data is present. - */ - public function getUploadedFiles(): array; - /** - * Create a new instance with the specified uploaded files. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated body parameters. - * - * @param array $uploadedFiles An array tree of UploadedFileInterface instances. - * @return static - * @throws \InvalidArgumentException if an invalid structure is provided. - */ - public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface; - /** - * Retrieve any parameters provided in the request body. - * - * If the request Content-Type is either application/x-www-form-urlencoded - * or multipart/form-data, and the request method is POST, this method MUST - * return the contents of $_POST. - * - * Otherwise, this method may return any results of deserializing - * the request body content; as parsing returns structured content, the - * potential types MUST be arrays or objects only. A null value indicates - * the absence of body content. - * - * @return null|array|object The deserialized body parameters, if any. - * These will typically be an array or object. - */ - public function getParsedBody(); - /** - * Return an instance with the specified body parameters. - * - * These MAY be injected during instantiation. - * - * If the request Content-Type is either application/x-www-form-urlencoded - * or multipart/form-data, and the request method is POST, use this method - * ONLY to inject the contents of $_POST. - * - * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of - * deserializing the request body content. Deserialization/parsing returns - * structured data, and, as such, this method ONLY accepts arrays or objects, - * or a null value if nothing was available to parse. - * - * As an example, if content negotiation determines that the request data - * is a JSON payload, this method could be used to create a request - * instance with the deserialized parameters. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated body parameters. - * - * @param null|array|object $data The deserialized body data. This will - * typically be in an array or object. - * @return static - * @throws \InvalidArgumentException if an unsupported argument type is - * provided. - */ - public function withParsedBody($data): ServerRequestInterface; - /** - * Retrieve attributes derived from the request. - * - * The request "attributes" may be used to allow injection of any - * parameters derived from the request: e.g., the results of path - * match operations; the results of decrypting cookies; the results of - * deserializing non-form-encoded message bodies; etc. Attributes - * will be application and request specific, and CAN be mutable. - * - * @return array Attributes derived from the request. - */ - public function getAttributes(): array; - /** - * Retrieve a single derived request attribute. - * - * Retrieves a single derived request attribute as described in - * getAttributes(). If the attribute has not been previously set, returns - * the default value as provided. - * - * This method obviates the need for a hasAttribute() method, as it allows - * specifying a default value to return if the attribute is not found. - * - * @see getAttributes() - * @param string $name The attribute name. - * @param mixed $default Default value to return if the attribute does not exist. - * @return mixed - */ - public function getAttribute(string $name, $default = null); - /** - * Return an instance with the specified derived request attribute. - * - * This method allows setting a single derived request attribute as - * described in getAttributes(). - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated attribute. - * - * @see getAttributes() - * @param string $name The attribute name. - * @param mixed $value The value of the attribute. - * @return static - */ - public function withAttribute(string $name, $value): ServerRequestInterface; - /** - * Return an instance that removes the specified derived request attribute. - * - * This method allows removing a single derived request attribute as - * described in getAttributes(). - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that removes - * the attribute. - * - * @see getAttributes() - * @param string $name The attribute name. - * @return static - */ - public function withoutAttribute(string $name): ServerRequestInterface; -} diff --git a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php b/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php deleted file mode 100644 index 432494ebfbbd9..0000000000000 --- a/src/wp-includes/php-ai-client/third-party/Psr/Http/Message/UploadedFileFactoryInterface.php +++ /dev/null @@ -1,28 +0,0 @@ -cache = new WP_AI_Client_Cache(); + } + + /** + * Test that the cache implements the scoped PSR-16 CacheInterface. + * + * @ticket TBD + */ + public function test_implements_cache_interface() { + $this->assertInstanceOf( + WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface::class, + $this->cache + ); + } + + /** + * Test that get returns default value on cache miss. + * + * @ticket TBD + */ + public function test_get_returns_default_on_miss() { + $this->assertNull( $this->cache->get( 'nonexistent' ) ); + $this->assertSame( 'fallback', $this->cache->get( 'nonexistent', 'fallback' ) ); + } + + /** + * Test set and get round-trip. + * + * @ticket TBD + */ + public function test_set_and_get() { + $this->assertTrue( $this->cache->set( 'key1', 'value1' ) ); + $this->assertSame( 'value1', $this->cache->get( 'key1' ) ); + } + + /** + * Test delete removes cached item. + * + * @ticket TBD + */ + public function test_delete() { + $this->cache->set( 'key1', 'value1' ); + $this->assertTrue( $this->cache->delete( 'key1' ) ); + $this->assertNull( $this->cache->get( 'key1' ) ); + } + + /** + * Test has returns false on cache miss. + * + * @ticket TBD + */ + public function test_has_returns_false_on_miss() { + $this->assertFalse( $this->cache->has( 'nonexistent' ) ); + } + + /** + * Test has returns true on cache hit. + * + * @ticket TBD + */ + public function test_has_returns_true_on_hit() { + $this->cache->set( 'key1', 'value1' ); + $this->assertTrue( $this->cache->has( 'key1' ) ); + } + + /** + * Test getMultiple returns values and defaults. + * + * @ticket TBD + */ + public function test_get_multiple() { + $this->cache->set( 'key1', 'value1' ); + $this->cache->set( 'key2', 'value2' ); + + $result = $this->cache->getMultiple( array( 'key1', 'key2', 'key3' ), 'default' ); + + $this->assertSame( 'value1', $result['key1'] ); + $this->assertSame( 'value2', $result['key2'] ); + $this->assertSame( 'default', $result['key3'] ); + } + + /** + * Test setMultiple stores multiple values. + * + * @ticket TBD + */ + public function test_set_multiple() { + $this->assertTrue( + $this->cache->setMultiple( + array( + 'key1' => 'value1', + 'key2' => 'value2', + ) + ) + ); + + $this->assertSame( 'value1', $this->cache->get( 'key1' ) ); + $this->assertSame( 'value2', $this->cache->get( 'key2' ) ); + } + + /** + * Test deleteMultiple removes multiple items. + * + * @ticket TBD + */ + public function test_delete_multiple() { + $this->cache->set( 'key1', 'value1' ); + $this->cache->set( 'key2', 'value2' ); + + $this->assertTrue( $this->cache->deleteMultiple( array( 'key1', 'key2' ) ) ); + $this->assertNull( $this->cache->get( 'key1' ) ); + $this->assertNull( $this->cache->get( 'key2' ) ); + } + + /** + * Test clear flushes the cache group. + * + * @ticket TBD + */ + public function test_clear() { + $this->cache->set( 'key1', 'value1' ); + + // WordPress default object cache supports flush_group. + $result = $this->cache->clear(); + + if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { + $this->assertTrue( $result ); + $this->assertNull( $this->cache->get( 'key1' ) ); + } else { + $this->assertFalse( $result ); + } + } + + /** + * Test set with integer TTL. + * + * @ticket TBD + */ + public function test_ttl_with_integer() { + $this->assertTrue( $this->cache->set( 'key1', 'value1', 3600 ) ); + $this->assertSame( 'value1', $this->cache->get( 'key1' ) ); + } + + /** + * Test set with DateInterval TTL. + * + * @ticket TBD + */ + public function test_ttl_with_date_interval() { + $ttl = new DateInterval( 'PT1H' ); + $this->assertTrue( $this->cache->set( 'key1', 'value1', $ttl ) ); + $this->assertSame( 'value1', $this->cache->get( 'key1' ) ); + } +} diff --git a/tools/php-ai-client/installer.sh b/tools/php-ai-client/installer.sh index f6ca19afd669e..6e128393406df 100755 --- a/tools/php-ai-client/installer.sh +++ b/tools/php-ai-client/installer.sh @@ -194,6 +194,51 @@ fi # Copy reorganized third-party dependencies. cp -R "$THIRD_PARTY_DIR/." "$TARGET_DIR/third-party/" +# Third-party paths to remove (not needed at runtime). +REMOVE_PATHS=( + # Composer plugin (build-time only). + "Http/Discovery/Composer" + + # HTTPlug client library (SDK uses PSR-18 directly). + "Http/Client" + + # Promise/async support (SDK is synchronous). + "Http/Promise" + + # Deprecated discovery classes superseded by Psr18ClientDiscovery / Psr17FactoryDiscovery. + "Http/Discovery/HttpClientDiscovery.php" + "Http/Discovery/HttpAsyncClientDiscovery.php" + "Http/Discovery/MessageFactoryDiscovery.php" + "Http/Discovery/UriFactoryDiscovery.php" + "Http/Discovery/StreamFactoryDiscovery.php" + "Http/Discovery/NotFoundException.php" + + # Convenience wrappers not used by the SDK. + "Http/Discovery/Psr17Factory.php" + "Http/Discovery/Psr18Client.php" + + # Mock strategy (not in default strategy list). + "Http/Discovery/Strategy/MockClientStrategy.php" + + # Server-side PSR-7 interfaces (SDK is client-side only). + "Psr/Http/Message/ServerRequestInterface.php" + "Psr/Http/Message/ServerRequestFactoryInterface.php" + "Psr/Http/Message/UploadedFileInterface.php" + "Psr/Http/Message/UploadedFileFactoryInterface.php" + + # PSR-14 interfaces not used by the event dispatcher. + "Psr/EventDispatcher/ListenerProviderInterface.php" + "Psr/EventDispatcher/StoppableEventInterface.php" + + # PSR-16 cache exception interfaces (never thrown or caught). + "Psr/SimpleCache/CacheException.php" + "Psr/SimpleCache/InvalidArgumentException.php" +) + +for path in "${REMOVE_PATHS[@]}"; do + rm -rf "$TARGET_DIR/third-party/$path" +done + # --------------------------------------------------------------------------- # Generate autoload.php # --------------------------------------------------------------------------- From 242f9f97b31c36b6c5e19d47978448150910772f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 11 Feb 2026 10:19:38 -0700 Subject: [PATCH 031/147] test: corrects PHP 8.5 compatibility --- .../tests/ai-client/wpAiClientPrompt.php | 10 +++++----- .../ai-client/wpAiClientPromptBuilder.php | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php index 7262b767c7865..c5a6604706c06 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php @@ -31,7 +31,7 @@ public function test_wraps_sdk_prompt_builder() { $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $property = $reflection->getProperty( 'builder' ); - $property->setAccessible( true ); + $this->assertInstanceOf( PromptBuilder::class, $property->getValue( $builder ) ); } @@ -46,12 +46,12 @@ public function test_passes_prompt_content() { $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.' ); @@ -67,12 +67,12 @@ public function test_no_prompt_creates_empty_builder() { $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.' ); diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 971c44d02fb4c..2eadf6d8fc879 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -82,13 +82,13 @@ private function create_text_model_metadata_with_input_support( string $id ): Mo private function get_wrapped_prompt_builder_property_value( WP_AI_Client_Prompt_Builder $builder, string $property ) { $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $builder_property = $reflection_class->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 ); } @@ -128,7 +128,7 @@ public function test_instantiation() { // 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 ); @@ -278,7 +278,7 @@ public function test_snake_case_to_camel_case_conversion() { $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 ); @@ -317,7 +317,7 @@ public function test_get_builder_callable() { $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' ); @@ -339,12 +339,12 @@ public function test_wrapped_builder_has_correct_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' ); } @@ -1358,11 +1358,11 @@ public function test_validate_messages_non_user_last_returns_wp_error() { // Manually add a model message as the last message. $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $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' ) ) ); From 7caa159c4bebb890ce5011ee1f3dde4d3f24716b Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 11 Feb 2026 10:22:49 -0700 Subject: [PATCH 032/147] test: corrects formatting issues --- tests/phpunit/tests/ai-client/wpAiClientPrompt.php | 1 - .../phpunit/tests/ai-client/wpAiClientPromptBuilder.php | 9 +-------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php index c5a6604706c06..131ac315eb3e7 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php @@ -31,7 +31,6 @@ public function test_wraps_sdk_prompt_builder() { $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $property = $reflection->getProperty( 'builder' ); - $this->assertInstanceOf( PromptBuilder::class, $property->getValue( $builder ) ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index 2eadf6d8fc879..d1d7f73e853f0 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -82,14 +82,11 @@ private function create_text_model_metadata_with_input_support( string $id ): Mo private function get_wrapped_prompt_builder_property_value( WP_AI_Client_Prompt_Builder $builder, string $property ) { $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $builder_property = $reflection_class->getProperty( 'builder' ); - - - $wrapped_builder = $builder_property->getValue( $builder ); + $wrapped_builder = $builder_property->getValue( $builder ); $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); $the_property = $reflection_class2->getProperty( $property ); - return $the_property->getValue( $wrapped_builder ); } @@ -279,7 +276,6 @@ public function test_snake_case_to_camel_case_conversion() { $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $conversion_method = $reflection_class->getMethod( 'snake_to_camel_case' ); - foreach ( $test_cases as $snake_case => $expected_camel_case ) { $actual_camel_case = $conversion_method->invoke( $prompt_builder, $snake_case ); $this->assertSame( $expected_camel_case, $actual_camel_case, "Failed converting {$snake_case} to {$expected_camel_case}" ); @@ -318,7 +314,6 @@ public function test_get_builder_callable() { $reflection_class = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $callable_method = $reflection_class->getMethod( 'get_builder_callable' ); - $callable = $callable_method->invoke( $prompt_builder, 'with_text' ); $this->assertTrue( is_callable( $callable ), 'get_builder_callable should return a valid callable' ); @@ -345,7 +340,6 @@ public function test_wrapped_builder_has_correct_registry() { $wrapped_builder_reflection = new ReflectionClass( get_class( $wrapped_builder ) ); $registry_property = $wrapped_builder_reflection->getProperty( 'registry' ); - $this->assertSame( $registry, $registry_property->getValue( $wrapped_builder ), 'Wrapped builder should have the same registry' ); } @@ -1363,7 +1357,6 @@ public function test_validate_messages_non_user_last_returns_wp_error() { $reflection_class2 = new ReflectionClass( get_class( $wrapped_builder ) ); $messages_property = $reflection_class2->getProperty( 'messages' ); - $messages = $messages_property->getValue( $wrapped_builder ); $messages[] = new ModelMessage( array( new MessagePart( 'Final model message' ) ) ); $messages_property->setValue( $wrapped_builder, $messages ); From c0145eb36e7e7d00cfebbb7c8f35e388c793353e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 11 Feb 2026 20:14:09 +0000 Subject: [PATCH 033/147] Site Health: Add test and debug data for Opcode Cache. Developed in https://github.com/WordPress/wordpress-develop/pull/9260 Props rollybueno, westonruter, swissspidy, peterwilsoncc, szepeviktor, ozgursar, oglekler, johnbillion, ugyensupport, abcd95, shailu25, noruzzaman. Fixes #63697. git-svn-id: https://develop.svn.wordpress.org/trunk@61612 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-debug-data.php | 77 +++++++++++++++++++ .../includes/class-wp-site-health.php | 62 +++++++++++++-- tests/phpunit/tests/admin/wpSiteHealth.php | 59 ++++++++++++++ 3 files changed, 192 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php index e7e90622dca12..98927f94e68fd 100644 --- a/src/wp-admin/includes/class-wp-debug-data.php +++ b/src/wp-admin/includes/class-wp-debug-data.php @@ -471,6 +471,83 @@ private static function get_wp_server(): array { 'debug' => $imagick_loaded, ); + // Opcode Cache. + if ( function_exists( 'opcache_get_status' ) ) { + $opcache_status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warning emitted in failure case. + + if ( false === $opcache_status ) { + $fields['opcode_cache'] = array( + 'label' => __( 'Opcode cache' ), + 'value' => __( 'Disabled by configuration' ), + 'debug' => 'not available', + ); + } else { + $fields['opcode_cache'] = array( + 'label' => __( 'Opcode cache' ), + 'value' => $opcache_status['opcache_enabled'] ? __( 'Enabled' ) : __( 'Disabled' ), + 'debug' => $opcache_status['opcache_enabled'], + ); + + if ( true === $opcache_status['opcache_enabled'] ) { + $fields['opcode_cache_memory_usage'] = array( + 'label' => __( 'Opcode cache memory usage' ), + 'value' => sprintf( + /* translators: 1: Used memory, 2: Total memory */ + __( '%1$s of %2$s' ), + size_format( $opcache_status['memory_usage']['used_memory'] ), + size_format( $opcache_status['memory_usage']['free_memory'] + $opcache_status['memory_usage']['used_memory'] ) + ), + 'debug' => sprintf( + '%s of %s', + $opcache_status['memory_usage']['used_memory'], + $opcache_status['memory_usage']['free_memory'] + $opcache_status['memory_usage']['used_memory'] + ), + ); + + if ( 0 !== $opcache_status['interned_strings_usage']['buffer_size'] ) { + $fields['opcode_cache_interned_strings_usage'] = array( + 'label' => __( 'Opcode cache interned strings usage' ), + 'value' => sprintf( + /* translators: 1: Percentage used, 2: Total memory, 3: Free memory */ + __( '%1$s%% of %2$s (%3$s free)' ), + number_format_i18n( ( $opcache_status['interned_strings_usage']['used_memory'] / $opcache_status['interned_strings_usage']['buffer_size'] ) * 100, 2 ), + size_format( $opcache_status['interned_strings_usage']['buffer_size'] ), + size_format( $opcache_status['interned_strings_usage']['free_memory'] ) + ), + 'debug' => sprintf( + '%s%% of %s (%s free)', + round( ( $opcache_status['interned_strings_usage']['used_memory'] / $opcache_status['interned_strings_usage']['buffer_size'] ) * 100, 2 ), + $opcache_status['interned_strings_usage']['buffer_size'], + $opcache_status['interned_strings_usage']['free_memory'] + ), + ); + } + + $fields['opcode_cache_hit_rate'] = array( + 'label' => __( 'Opcode cache hit rate' ), + 'value' => sprintf( + /* translators: %s: Hit rate percentage */ + __( '%s%%' ), + number_format_i18n( $opcache_status['opcache_statistics']['opcache_hit_rate'], 2 ) + ), + 'debug' => round( $opcache_status['opcache_statistics']['opcache_hit_rate'], 2 ), + ); + + $fields['opcode_cache_full'] = array( + 'label' => __( 'Is the Opcode cache full?' ), + 'value' => $opcache_status['cache_full'] ? __( 'Yes' ) : __( 'No' ), + 'debug' => $opcache_status['cache_full'], + ); + } + } + } else { + $fields['opcode_cache'] = array( + 'label' => __( 'Opcode cache' ), + 'value' => __( 'Disabled' ), + 'debug' => 'not available', + ); + } + // Pretty permalinks. $pretty_permalinks_supported = got_url_rewrite(); diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index dd537296a8655..93a45f56236c3 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -2755,6 +2755,52 @@ public function get_test_search_engine_visibility() { return $result; } + /** + * Tests if opcode cache is enabled and available. + * + * @since 7.0.0 + * + * @return array> The test result. + */ + public function get_test_opcode_cache(): array { + $opcode_cache_enabled = false; + if ( function_exists( 'opcache_get_status' ) ) { + $status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warning emitted in failure case. + if ( $status && true === $status['opcache_enabled'] ) { + $opcode_cache_enabled = true; + } + } + + $result = array( + 'label' => __( 'Opcode cache is enabled' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Performance' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + __( 'Opcode cache improves PHP performance by storing precompiled script bytecode in memory, reducing the need for PHP to load and parse scripts on each request.' ) + ), + 'actions' => sprintf( + '

%s %s

', + esc_url( 'https://www.php.net/manual/en/book.opcache.php' ), + __( 'Learn more about OPcache.' ), + /* translators: Hidden accessibility text. */ + __( '(opens in a new tab)' ) + ), + 'test' => 'opcode_cache', + ); + + if ( ! $opcode_cache_enabled ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Opcode cache is not enabled' ); + $result['description'] .= '

' . __( 'Enabling this cache can significantly improve the performance of your site.' ) . '

'; + } + + return $result; + } + /** * Returns a set of tests that belong to the site status page. * @@ -2847,6 +2893,10 @@ public static function get_tests() { 'label' => __( 'Search Engine Visibility' ), 'test' => 'search_engine_visibility', ), + 'opcode_cache' => array( + 'label' => __( 'Opcode cache' ), + 'test' => 'opcode_cache', + ), ), 'async' => array( 'dotorg_communication' => array( @@ -3415,14 +3465,14 @@ public function get_page_cache_headers() { 'x-srcache-fetch-status' => $cache_hit_callback, // Generic caching proxies (Nginx, Varnish, etc.) - 'x-cache' => $cache_hit_callback, - 'x-cache-status' => $cache_hit_callback, - 'x-litespeed-cache' => $cache_hit_callback, - 'x-proxy-cache' => $cache_hit_callback, - 'via' => '', + 'x-cache' => $cache_hit_callback, + 'x-cache-status' => $cache_hit_callback, + 'x-litespeed-cache' => $cache_hit_callback, + 'x-proxy-cache' => $cache_hit_callback, + 'via' => '', // Cloudflare - 'cf-cache-status' => $cache_hit_callback, + 'cf-cache-status' => $cache_hit_callback, ); /** diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 2d32bbb14ec4d..12b563cdfe41f 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -572,4 +572,63 @@ public static function set_autoloaded_option( $bytes = 800000 ) { // Force autoloading so that WordPress core does not override it. See https://core.trac.wordpress.org/changeset/57920. add_option( 'test_set_autoloaded_option', $heavy_option_string, '', true ); } + + /** + * Tests get_test_opcode_cache() return structure. + * + * @ticket 63697 + * + * @covers ::get_test_opcode_cache() + */ + public function test_get_test_opcode_cache_return_structure() { + $result = $this->instance->get_test_opcode_cache(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'label', $result ); + $this->assertArrayHasKey( 'status', $result ); + $this->assertArrayHasKey( 'badge', $result ); + $this->assertArrayHasKey( 'description', $result ); + $this->assertArrayHasKey( 'actions', $result ); + $this->assertArrayHasKey( 'test', $result ); + + $this->assertSame( 'opcode_cache', $result['test'] ); + $this->assertSame( + array( + 'label' => __( 'Performance' ), + 'color' => 'blue', + ), + $result['badge'] + ); + $this->assertContains( $result['status'], array( 'good', 'recommended' ), 'Status must be good or recommended.' ); + } + + /** + * Tests get_test_opcode_cache() result when opcode cache is enabled or not. + * + * Covers: opcache enabled, disabled, not available, and opcache_get_status() returns false. + * + * @ticket 63697 + * + * @covers ::get_test_opcode_cache() + */ + public function test_get_test_opcode_cache_result_by_environment() { + $result = $this->instance->get_test_opcode_cache(); + + $opcache_enabled = false; + if ( function_exists( 'opcache_get_status' ) ) { + $status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warning emitted in failure case. + if ( $status && true === $status['opcache_enabled'] ) { + $opcache_enabled = true; + } + } + + if ( $opcache_enabled ) { + $this->assertSame( 'good', $result['status'], 'When opcache is enabled, status should be "good".' ); + $this->assertSame( __( 'Opcode cache is enabled' ), $result['label'] ); + } else { + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( __( 'Opcode cache is not enabled' ), $result['label'] ); + $this->assertStringContainsString( __( 'Enabling this cache can significantly improve the performance of your site.' ), $result['description'] ); + } + } } From a77775692568bdf1ab6d9395d7adc1dbbbee41f2 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Wed, 11 Feb 2026 20:26:24 +0000 Subject: [PATCH 034/147] Build/Test Tools: Update the Playground PR comment in GitHub Actions. This changeset removes the "Plugin and Theme Directories cannot be accessed within Playground" bullet point from the Playground Pull Request Comment GitHub Action, as it is not the case anymore. Props audrasjb, westonruter. Fixes #64578. git-svn-id: https://develop.svn.wordpress.org/trunk@61613 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/pull-request-comments.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pull-request-comments.yml b/.github/workflows/pull-request-comments.yml index dc7e6e7c7a7e6..da30e2feb7f11 100644 --- a/.github/workflows/pull-request-comments.yml +++ b/.github/workflows/pull-request-comments.yml @@ -167,7 +167,6 @@ jobs: [WordPress Playground](https://developer.wordpress.org/playground/) is an experimental project that creates a full WordPress instance entirely within the browser. ### Some things to be aware of - - The Plugin and Theme Directories cannot be accessed within Playground. - All changes will be lost when closing a tab with a Playground instance. - All changes will be lost when refreshing the page. - A fresh instance is created each time the link below is clicked. From 6cb574b0f75d8a87d348d22fb3493315d5238a34 Mon Sep 17 00:00:00 2001 From: Jb Audras Date: Wed, 11 Feb 2026 21:09:12 +0000 Subject: [PATCH 035/147] Site Health: Allow direct linking to site health check result. This changeset does the following changes: - Add an ID to each accordion button - Update the URL hash each time an accordion button is clicked - On page load, open the related accordion when provided This way, people can use the URL of the page to share a direct link to the site health info section they want. Props sippis, kabir93, audrasjb, saratheonline, pratiklondhe, vgnavada, SirLouen, nikunj8866, pmbaldha, sajjad67, huzaifaalmesbah, westonruter. Fixes #62846. git-svn-id: https://develop.svn.wordpress.org/trunk@61614 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/_enqueues/admin/site-health.js | 18 ++++++++++++++++++ src/wp-admin/site-health-info.php | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 416295df69b17..57d5c9cbcf289 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -44,6 +44,10 @@ jQuery( function( $ ) { $( '.health-check-accordion' ).on( 'click', '.health-check-accordion-trigger', function() { var isExpanded = ( 'true' === $( this ).attr( 'aria-expanded' ) ); + if ( $( this ).prop( 'id' ) ) { + window.location.hash = $( this ).prop( 'id' ); + } + if ( isExpanded ) { $( this ).attr( 'aria-expanded', 'false' ); $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', true ); @@ -53,6 +57,20 @@ jQuery( function( $ ) { } } ); + /* global setTimeout */ + wp.domReady( function() { + // Get hash from query string and open the related accordion. + var hash = window.location.hash; + + if ( hash ) { + var requestedPanel = $( hash ); + + if ( requestedPanel.is( '.health-check-accordion-trigger' ) ) { + requestedPanel.trigger( 'click' ); + } + } + } ); + // Site Health test handling. $( '.site-health-view-passed' ).on( 'click', function() { diff --git a/src/wp-admin/site-health-info.php b/src/wp-admin/site-health-info.php index bfdd77df01553..faffb21636827 100644 --- a/src/wp-admin/site-health-info.php +++ b/src/wp-admin/site-health-info.php @@ -73,7 +73,7 @@ ?>

-