diff --git a/wp-includes/class-wp-connector-registry.php b/wp-includes/class-wp-connector-registry.php new file mode 100644 index 0000000000..75a6b8ef0c --- /dev/null +++ b/wp-includes/class-wp-connector-registry.php @@ -0,0 +1,289 @@ + + * @phpstan-var array + */ + private array $registered_connectors = array(); + + /** + * Registers a new connector. + * + * @since 7.0.0 + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string|null $logo_url Optional. URL to the connector's logo image. Default null. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. + * + * @phpstan-param Connector $args + * @phpstan-return Connector|null + */ + public function register( string $id, array $args ): ?array { + if ( ! preg_match( '/^[a-z0-9_]+$/', $id ) ) { + _doing_it_wrong( + __METHOD__, + __( + 'Connector ID must contain only lowercase alphanumeric characters and underscores.' + ), + '7.0.0' + ); + return null; + } + + if ( $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( ! empty( $args['logo_url'] ) && is_string( $args['logo_url'] ) ) { + $connector['logo_url'] = $args['logo_url']; + } + + if ( 'api_key' === $args['authentication']['method'] ) { + $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'] ?? null; + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * @since 7.0.0 + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + * + * @phpstan-return Connector|null + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. + * + * @since 7.0.0 + * + * @see wp_get_connectors() + * + * @return array The array of registered connectors keyed by connector ID. + * @phpstan-return array + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `wp_is_connector_registered()` function. + * + * @since 7.0.0 + * + * @see wp_is_connector_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `wp_get_connector()` function. + * + * @since 7.0.0 + * + * @see wp_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + * @phpstan-return Connector|null + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Retrieves the main instance of the registry class. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null if not yet initialized. + */ + public static function get_instance(): ?self { + return self::$instance; + } + + /** + * Sets the main instance of the registry class. + * + * @since 7.0.0 + * @access private + * + * @param WP_Connector_Registry $registry The registry instance. + */ + public static function set_instance( WP_Connector_Registry $registry ): void { + if ( ! doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The connector registry instance must be set during the init action.' ), + '7.0.0' + ); + return; + } + + self::$instance = $registry; + } +} diff --git a/wp-includes/connectors.php b/wp-includes/connectors.php index 0da6035370..60f97839da 100644 --- a/wp-includes/connectors.php +++ b/wp-includes/connectors.php @@ -10,6 +10,244 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +/** + * Checks if a connector is registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ +function wp_is_connector_registered( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $id ); +} + +/** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ +function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); +} + +/** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ +function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Resolves an AI provider logo file path to a URL. + * + * Converts an absolute file path to a plugin URL. The path must reside within + * the plugins or must-use plugins directory. + * + * @since 7.0.0 + * @access private + * + * @param string $path Absolute path to the logo file. + * @return string|null The URL to the logo file, or null if the path is invalid. + */ +function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { + if ( ! $path ) { + return null; + } + + $path = wp_normalize_path( $path ); + + if ( ! file_exists( $path ) ) { + return null; + } + + $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); + if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); + } + + $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + if ( str_starts_with( $path, $plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + } + + _doing_it_wrong( + __FUNCTION__, + __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ), + '7.0.0' + ); + + return null; +} + +/** + * Initializes the connector registry with default connectors and fires the registration action. + * + * Creates the registry instance, registers built-in connectors (which cannot be unhooked), + * and then fires the `wp_connectors_init` action for plugins to register their own connectors. + * + * @since 7.0.0 + * @access private + */ +function _wp_connectors_init(): void { + $registry = new WP_Connector_Registry(); + WP_Connector_Registry::set_instance( $registry ); + // Built-in connectors. + $defaults = array( + 'anthropic' => array( + 'name' => 'Anthropic', + 'description' => __( 'Text generation with Claude.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-anthropic', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.claude.com/settings/keys', + ), + ), + 'google' => array( + 'name' => 'Google', + 'description' => __( 'Text and image generation with Gemini and Imagen.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-google', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://aistudio.google.com/api-keys', + ), + ), + 'openai' => array( + 'name' => 'OpenAI', + 'description' => __( 'Text and image generation with GPT and Dall-E.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-openai', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.openai.com/api-keys', + ), + ), + ); + + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. + $ai_registry = AiClient::defaultRegistry(); + + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); + $provider_metadata = $provider_class_name::metadata(); + + $auth_method = $provider_metadata->getAuthenticationMethod(); + $is_api_key = null !== $auth_method && $auth_method->isApiKey(); + + if ( $is_api_key ) { + $credentials_url = $provider_metadata->getCredentialsUrl(); + $authentication = array( + 'method' => 'api_key', + 'credentials_url' => $credentials_url ? $credentials_url : null, + ); + } else { + $authentication = array( 'method' => 'none' ); + } + + $name = $provider_metadata->getName(); + $description = $provider_metadata->getDescription(); + $logo_url = $provider_metadata->getLogoPath() + ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + : null; + + if ( isset( $defaults[ $connector_id ] ) ) { + // Override fields with non-empty registry values. + if ( $name ) { + $defaults[ $connector_id ]['name'] = $name; + } + if ( $description ) { + $defaults[ $connector_id ]['description'] = $description; + } + if ( $logo_url ) { + $defaults[ $connector_id ]['logo_url'] = $logo_url; + } + // Always update auth method; keep existing credentials_url as fallback. + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; + if ( ! empty( $authentication['credentials_url'] ) ) { + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + } + } else { + $defaults[ $connector_id ] = array( + 'name' => $name ? $name : ucwords( $connector_id ), + 'description' => $description ? $description : '', + 'type' => 'ai_provider', + 'authentication' => $authentication, + 'logo_url' => $logo_url, + ); + } + } + + // Register all default connectors directly on the registry. + foreach ( $defaults as $id => $args ) { + $registry->register( $id, $args ); + } + + /** + * Fires when the connector registry is ready for plugins to register connectors. + * + * Default connectors have already been registered at this point and cannot be + * unhooked. Use `$registry->register()` within this action to add new connectors. + * + * Example usage: + * + * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { + * $registry->register( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } ); + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $registry Connector registry instance. + */ + do_action( 'wp_connectors_init', $registry ); +} /** * Masks an API key, showing only the last 4 characters. @@ -116,99 +354,8 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * } */ function _wp_connectors_get_connector_settings(): array { - $connectors = array( - 'anthropic' => array( - 'name' => 'Anthropic', - 'description' => __( 'Text generation with Claude.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-anthropic', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.claude.com/settings/keys', - ), - ), - 'google' => array( - 'name' => 'Google', - 'description' => __( 'Text and image generation with Gemini and Imagen.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-google', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://aistudio.google.com/api-keys', - ), - ), - 'openai' => array( - 'name' => 'OpenAI', - 'description' => __( 'Text and image generation with GPT and Dall-E.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-openai', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.openai.com/api-keys', - ), - ), - ); - - $registry = AiClient::defaultRegistry(); - - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class_name = $registry->getProviderClassName( $connector_id ); - $provider_metadata = $provider_class_name::metadata(); - - $auth_method = $provider_metadata->getAuthenticationMethod(); - $is_api_key = null !== $auth_method && $auth_method->isApiKey(); - - if ( $is_api_key ) { - $credentials_url = $provider_metadata->getCredentialsUrl(); - $authentication = array( - 'method' => 'api_key', - 'credentials_url' => $credentials_url ? $credentials_url : null, - ); - } else { - $authentication = array( 'method' => 'none' ); - } - - $name = $provider_metadata->getName(); - $description = $provider_metadata->getDescription(); - - if ( isset( $connectors[ $connector_id ] ) ) { - // Override fields with non-empty registry values. - if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; - } - if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; - } - // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; - if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; - } - } else { - $connectors[ $connector_id ] = array( - 'name' => $name ? $name : ucwords( $connector_id ), - 'description' => $description ? $description : '', - 'type' => 'ai_provider', - 'authentication' => $authentication, - ); - } - } - + $connectors = wp_get_connectors(); ksort( $connectors ); - - // Add setting_name for connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; - } - } - return $connectors; } @@ -282,12 +429,19 @@ add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 * @access private */ function _wp_register_default_connector_settings(): void { + $ai_registry = AiClient::defaultRegistry(); + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } + // Skip registering the setting if the provider is not in the registry. + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + $setting_name = $auth['setting_name']; register_setting( 'connectors', @@ -330,7 +484,7 @@ add_action( 'init', '_wp_register_default_connector_settings', 20 ); */ function _wp_connectors_pass_default_keys_to_ai_client(): void { try { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; @@ -341,18 +495,22 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } - $registry->setProviderRequestAuthentication( + $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); + if ( '' === $api_key ) { + continue; + } + + $ai_registry->setProviderRequestAuthentication( $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); } } catch ( Exception $e ) { - wp_trigger_error( __FUNCTION__, $e->getMessage() ); + wp_trigger_error( __FUNCTION__, $e->getMessage() ); } } add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index 796cf00ec8..ad5ac96887 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -539,6 +539,9 @@ add_action( 'parse_request', 'rest_api_loaded' ); add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); +// Connectors API. +add_action( 'init', '_wp_connectors_init' ); + // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); diff --git a/wp-includes/version.php b/wp-includes/version.php index 6968763f00..3378fdd65a 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.0-beta4-61942'; +$wp_version = '7.0-beta4-61943'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/wp-settings.php b/wp-settings.php index 023cdccd5e..dab1d8fd4c 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -294,6 +294,7 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-event-dispatch 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'; require ABSPATH . WPINC . '/ai-client.php'; +require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php';