diff --git a/src/wp-admin/menu.php b/src/wp-admin/menu.php index e544175d153b4..a682f715ac88a 100644 --- a/src/wp-admin/menu.php +++ b/src/wp-admin/menu.php @@ -410,6 +410,9 @@ function _add_plugin_file_editor_to_tools() { $submenu['options-general.php'][30] = array( __( 'Media' ), 'manage_options', 'options-media.php' ); $submenu['options-general.php'][40] = array( __( 'Permalinks' ), 'manage_options', 'options-permalink.php' ); $submenu['options-general.php'][45] = array( __( 'Privacy' ), 'manage_privacy_options', 'options-privacy.php' ); +if ( ! empty( $GLOBALS['wp_ai_client_credentials_manager']->get_all_cloud_providers_metadata() ) ) { + $submenu['options-general.php'][47] = array( __( 'AI Services' ), 'manage_options', 'options-ai.php' ); +} $_wp_last_utility_menu = 80; // The index of the last top-level menu in the utility menu group. diff --git a/src/wp-admin/options-ai.php b/src/wp-admin/options-ai.php new file mode 100644 index 0000000000000..df625f1737d02 --- /dev/null +++ b/src/wp-admin/options-ai.php @@ -0,0 +1,102 @@ +get_all_cloud_providers_metadata(); + +$settings_section = 'wp-ai-client-provider-credentials'; + +add_settings_section( + $settings_section, + '', + static function () { + ?> +

+ +

+ getId(); + $provider_name = $provider_metadata->getName(); + $provider_credentials_url = $provider_metadata->getCredentialsUrl(); + + $field_id = 'wp-ai-client-provider-api-key-' . $provider_id; + $field_args = array( + 'type' => 'password', + 'label_for' => $field_id, + 'id' => $field_id, + 'name' => WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS . '[' . $provider_id . ']', + ); + if ( $provider_credentials_url ) { + $field_args['description'] = sprintf( + /* translators: 1: AI provider name, 2: URL to the provider's API credentials page. */ + __( 'Create and manage your %1$s API keys in the %1$s account settings (opens in a new tab).' ), + $provider_name, + esc_url( $provider_credentials_url ) + ); + } + + add_settings_field( + $field_id, + $provider_name, + 'wp_ai_client_render_credential_field', + 'ai', + $settings_section, + $field_args + ); +} + +$ai_help = '

' . __( 'This screen allows you to configure API credentials for AI service providers. These credentials are used by AI-powered features throughout your site.' ) . '

'; +$ai_help .= '

' . __( 'You must click the Save Changes button at the bottom of the screen for new settings to take effect.' ) . '

'; + +get_current_screen()->add_help_tab( + array( + 'id' => 'overview', + 'title' => __( 'Overview' ), + 'content' => $ai_help, + ) +); + +get_current_screen()->set_help_sidebar( + '

' . __( 'For more information:' ) . '

' . + '

' . __( 'Support forums' ) . '

' +); + +require_once ABSPATH . 'wp-admin/admin-header.php'; + +?> + +
+

+ +
+ + + +
+ +
+ + diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php index 57c22be86d367..e297cdc7845ac 100644 --- a/src/wp-admin/options.php +++ b/src/wp-admin/options.php @@ -159,6 +159,7 @@ $allowed_options['misc'] = array(); $allowed_options['options'] = array(); $allowed_options['privacy'] = array(); +$allowed_options['ai'] = array(); /** * Filters whether the post-by-email functionality is enabled. diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 88a1fdf323f52..8d5a1db314617 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -32,3 +32,72 @@ function wp_ai_client_prompt( $prompt = null ) { return new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), $prompt ); } + +/** + * Renders a credential input field for the AI Services settings page. + * + * @since 7.0.0 + * @access private + * + * @param array $args { + * Field arguments set up during add_settings_field(). + * + * @type string $type Input type. Default 'text'. + * @type string $id Field ID attribute. + * @type string $name Field name attribute, may include array notation. + * @type string $description Optional. Field description HTML. + * } + */ +function wp_ai_client_render_credential_field( $args ) { + $type = isset( $args['type'] ) ? $args['type'] : 'text'; + $id = isset( $args['id'] ) ? $args['id'] : ''; + $name = isset( $args['name'] ) ? $args['name'] : ''; + $description = isset( $args['description'] ) ? $args['description'] : ''; + $description_id = $id . '_description'; + + if ( str_contains( $name, '[' ) ) { + $parts = explode( '[', $name, 2 ); + $option = get_option( $parts[0] ); + $subkey = trim( $parts[1], ']' ); + if ( is_array( $option ) && isset( $option[ $subkey ] ) && is_string( $option[ $subkey ] ) ) { + $value = $option[ $subkey ]; + } else { + $value = ''; + } + } else { + $option = get_option( $name ); + $value = is_string( $option ) ? $option : ''; + } + + ?> + + > + array( + 'class' => array(), + 'href' => array(), + 'target' => array(), + 'rel' => array(), + ), + 'strong' => array(), + 'em' => array(), + 'span' => array( + 'class' => array(), + ), + ); + ?> +

+ +

+ getRegisteredProviderIds(); + foreach ( $provider_ids as $provider_id ) { + // If the provider was already found via another client class, just add this client class name. + if ( isset( $wp_ai_client_providers_metadata[ $provider_id ] ) ) { + if ( ! is_array( $wp_ai_client_providers_metadata[ $provider_id ]['ai_client_classnames'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Invalid format for collected provider AI client class names.' ), + '7.0.0' + ); + continue; + } + $wp_ai_client_providers_metadata[ $provider_id ]['ai_client_classnames'][ AiClient::class ] = true; + continue; + } + + // Get the provider metadata and add it to the global. + $provider_class_name = $registry->getProviderClassName( $provider_id ); + $provider_metadata = $provider_class_name::metadata(); + + $wp_ai_client_providers_metadata[ $provider_id ] = array_merge( + $provider_metadata->toArray(), + array( + 'ai_client_classnames' => array( AiClient::class => true ), + ) + ); + } + } + + /** + * Returns the metadata for all registered providers across all instances of the PHP AI Client SDK. + * + * @since 7.0.0 + * + * @return array Array of provider metadata objects, + * keyed by provider ID. + */ + public function get_all_providers_metadata() { + global $wp_ai_client_providers_metadata; + + if ( ! isset( $wp_ai_client_providers_metadata ) ) { + $wp_ai_client_providers_metadata = array(); + } + + return array_map( + static function ( array $provider_metadata ) { + unset( $provider_metadata['ai_client_classnames'] ); + return \WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray( $provider_metadata ); + }, + $wp_ai_client_providers_metadata + ); + } + + /** + * Returns the metadata for all registered cloud providers across all instances of the PHP AI Client SDK. + * + * @since 7.0.0 + * + * @return array Array of cloud provider metadata objects, + * keyed by provider ID. + */ + public function get_all_cloud_providers_metadata() { + $all_providers = $this->get_all_providers_metadata(); + + return array_filter( + $all_providers, + static function ( $metadata ) { + return $metadata->getType()->isCloud(); + } + ); + } + + /** + * Registers the settings for storing the API credentials. + * + * The setting will only be registered once, even if called multiple times. + * + * @since 7.0.0 + */ + public function register_settings() { + // Avoid registering the setting multiple times. + $registered_settings = get_registered_settings(); + if ( isset( $registered_settings[ self::OPTION_PROVIDER_CREDENTIALS ] ) ) { + return; + } + + register_setting( + self::OPTION_GROUP, + self::OPTION_PROVIDER_CREDENTIALS, + array( + 'type' => 'object', + 'default' => array(), + 'sanitize_callback' => array( $this, 'sanitize_credentials' ), + ) + ); + } + + /** + * Sanitizes the provider credentials before saving. + * + * Filters out unknown providers and sanitizes each API key value. + * + * @since 7.0.0 + * + * @param mixed $credentials The raw credentials input. + * @return array Sanitized credentials array. + */ + public function sanitize_credentials( $credentials ) { + if ( ! is_array( $credentials ) ) { + return array(); + } + + // Assume that all cloud providers require an API key. + $providers_metadata_keyed_by_ids = $this->get_all_cloud_providers_metadata(); + + $credentials = array_intersect_key( $credentials, $providers_metadata_keyed_by_ids ); + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $api_key ) ) { + unset( $credentials[ $provider_id ] ); + continue; + } + $credentials[ $provider_id ] = sanitize_text_field( $api_key ); + } + return $credentials; + } + + /** + * Passes the stored API credentials to the PHP AI Client SDK. + * + * This method should be called on every request, before any API requests + * are made via the PHP AI Client SDK. + * + * @since 7.0.0 + */ + public function pass_credentials_to_client() { + $credentials = get_option( self::OPTION_PROVIDER_CREDENTIALS, array() ); + if ( ! is_array( $credentials ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Invalid format for stored provider credentials option.' ), + '7.0.0' + ); + return; + } + + $registry = AiClient::defaultRegistry(); + + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $api_key ) || '' === $api_key ) { + continue; + } + + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $api_key ) + ); + } + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 301b846343ee2..45d1dd7e22c5f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -531,6 +531,7 @@ add_action( 'init', 'rest_api_init' ); add_action( 'rest_api_init', 'rest_api_default_filters', 10, 1 ); add_action( 'rest_api_init', 'register_initial_settings', 10 ); +add_action( 'admin_init', 'register_initial_settings' ); add_action( 'rest_api_init', 'create_initial_rest_routes', 99 ); add_action( 'parse_request', 'rest_api_loaded' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 970ac39652195..23741b9d32d71 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2969,6 +2969,8 @@ function register_initial_settings() { 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); + + $GLOBALS['wp_ai_client_credentials_manager']->register_settings(); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index 90741401e800c..bf102b45bff21 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -477,6 +477,8 @@ WP_AI_Client_Discovery_Strategy::init(); WordPress\AiClient\AiClient::setCache( new WP_AI_Client_Cache() ); WordPress\AiClient\AiClient::setEventDispatcher( new WP_AI_Client_Event_Dispatcher() ); +require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-credentials-manager.php'; +$GLOBALS['wp_ai_client_credentials_manager'] = new WP_AI_Client_Credentials_Manager(); // Load multisite-specific files. if ( is_multisite() ) { @@ -772,6 +774,10 @@ */ do_action( 'init' ); +// WP AI Client - Collect providers and pass credentials after plugins have loaded. +$GLOBALS['wp_ai_client_credentials_manager']->collect_providers(); +$GLOBALS['wp_ai_client_credentials_manager']->pass_credentials_to_client(); + // Check site status. if ( is_multisite() ) { $file = ms_site_check(); diff --git a/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php b/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php new file mode 100644 index 0000000000000..7dc00245b0702 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php @@ -0,0 +1,372 @@ +saved_providers_metadata = $wp_ai_client_providers_metadata; + } + + /** + * Restores state after each test. + */ + public function tear_down() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = $this->saved_providers_metadata; + delete_option( WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + parent::tear_down(); + } + + /** + * Test that collect_providers initializes the global as an array. + * + * @ticket TBD + */ + public function test_collect_providers_initializes_global() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = null; + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + $this->assertIsArray( $wp_ai_client_providers_metadata ); + + // Each entry should have the expected structure. + foreach ( $wp_ai_client_providers_metadata as $provider_id => $metadata ) { + $this->assertIsString( $provider_id ); + $this->assertArrayHasKey( 'id', $metadata ); + $this->assertArrayHasKey( 'name', $metadata ); + $this->assertArrayHasKey( 'type', $metadata ); + $this->assertArrayHasKey( 'ai_client_classnames', $metadata ); + $this->assertIsArray( $metadata['ai_client_classnames'] ); + } + } + + /** + * Test that collect_providers does not duplicate entries when called multiple times. + * + * @ticket TBD + */ + public function test_collect_providers_deduplicates() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = null; + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + $first_count = count( $wp_ai_client_providers_metadata ); + + // Calling again should not duplicate providers. + $manager->collect_providers(); + $this->assertCount( $first_count, $wp_ai_client_providers_metadata ); + } + + /** + * Test that collect_providers preserves existing entries in the global. + * + * @ticket TBD + */ + public function test_collect_providers_preserves_existing_entries() { + global $wp_ai_client_providers_metadata; + + // Seed the global with a fake provider entry not in the SDK registry. + $wp_ai_client_providers_metadata = array( + 'test-provider' => array( + 'id' => 'test-provider', + 'name' => 'Test Provider', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'SomeOtherClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + // The test-provider entry should still exist (not removed by collect_providers). + $this->assertArrayHasKey( 'test-provider', $wp_ai_client_providers_metadata ); + $this->assertSame( 'Test Provider', $wp_ai_client_providers_metadata['test-provider']['name'] ); + } + + /** + * Test that get_all_providers_metadata returns ProviderMetadata objects. + * + * @ticket TBD + */ + public function test_get_all_providers_metadata_returns_provider_metadata_objects() { + $manager = new WP_AI_Client_Credentials_Manager(); + $providers = $manager->get_all_providers_metadata(); + + $this->assertIsArray( $providers ); + foreach ( $providers as $metadata ) { + $this->assertInstanceOf( WordPress\AiClient\Providers\DTO\ProviderMetadata::class, $metadata ); + } + } + + /** + * Test that get_all_cloud_providers_metadata only returns cloud providers. + * + * @ticket TBD + */ + public function test_get_all_cloud_providers_metadata_filters_to_cloud_only() { + $manager = new WP_AI_Client_Credentials_Manager(); + $cloud_providers = $manager->get_all_cloud_providers_metadata(); + + $this->assertIsArray( $cloud_providers ); + foreach ( $cloud_providers as $metadata ) { + $this->assertTrue( + $metadata->getType()->isCloud(), + sprintf( 'Provider "%s" should be a cloud provider.', $metadata->getId() ) + ); + } + } + + /** + * Test that register_settings creates the setting. + * + * @ticket TBD + */ + public function test_register_settings_creates_setting() { + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->register_settings(); + + $registered = get_registered_settings(); + $this->assertArrayHasKey( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + $registered + ); + + // Clean up. + unregister_setting( 'ai', WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + } + + /** + * Test that register_settings does not register twice. + * + * @ticket TBD + */ + public function test_register_settings_idempotent() { + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->register_settings(); + $manager->register_settings(); + + $registered = get_registered_settings(); + $this->assertArrayHasKey( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + $registered + ); + + // Clean up. + unregister_setting( 'ai', WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + } + + /** + * Test that sanitize_credentials filters out unknown providers. + * + * @ticket TBD + */ + public function test_sanitize_credentials_filters_unknown_providers() { + global $wp_ai_client_providers_metadata; + + // Seed a cloud provider in the global. + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => 'sk-valid-key', + 'nonexistent_provider' => 'sk-invalid-key', + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayHasKey( 'test-cloud', $result ); + $this->assertArrayNotHasKey( 'nonexistent_provider', $result ); + } + + /** + * Test that sanitize_credentials applies sanitize_text_field. + * + * @ticket TBD + */ + public function test_sanitize_credentials_sanitizes_values() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => " sk-key-with-whitespace\t", + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertSame( 'sk-key-with-whitespace', $result['test-cloud'] ); + } + + /** + * Test that sanitize_credentials returns empty array for non-array input. + * + * @ticket TBD + */ + public function test_sanitize_credentials_returns_empty_for_non_array() { + $manager = new WP_AI_Client_Credentials_Manager(); + + $this->assertSame( array(), $manager->sanitize_credentials( 'not-an-array' ) ); + $this->assertSame( array(), $manager->sanitize_credentials( null ) ); + } + + /** + * Test that sanitize_credentials removes non-string values. + * + * @ticket TBD + */ + public function test_sanitize_credentials_removes_non_string_values() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => array( 'not', 'a', 'string' ), + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayNotHasKey( 'test-cloud', $result ); + } + + /** + * Test that sanitize_credentials filters out non-cloud providers. + * + * @ticket TBD + */ + public function test_sanitize_credentials_filters_non_cloud_providers() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + 'test-server' => array( + 'id' => 'test-server', + 'name' => 'Test Server', + 'type' => 'server', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => 'sk-cloud-key', + 'test-server' => 'sk-server-key', + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayHasKey( 'test-cloud', $result ); + $this->assertArrayNotHasKey( 'test-server', $result ); + } + + /** + * Test that pass_credentials_to_client skips providers not in the registry. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_skips_unregistered_providers() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set credentials for a provider that doesn't exist in the SDK registry. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + array( 'nonexistent-provider' => 'sk-test-key' ) + ); + + // This should not throw any errors. + $manager->pass_credentials_to_client(); + + // Verify by checking the registry doesn't have the provider. + $registry = WordPress\AiClient\AiClient::defaultRegistry(); + $this->assertFalse( $registry->hasProvider( 'nonexistent-provider' ) ); + } + + /** + * Test that pass_credentials_to_client handles invalid option value gracefully. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_handles_invalid_option() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set a non-array value for the option. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + 'not-an-array' + ); + + // This should trigger _doing_it_wrong but not fatal. + $this->setExpectedIncorrectUsage( 'WP_AI_Client_Credentials_Manager::pass_credentials_to_client' ); + $manager->pass_credentials_to_client(); + } + + /** + * Test that pass_credentials_to_client skips empty API keys. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_skips_empty_keys() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set credentials with empty values for a non-existent provider. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + array( 'some-provider' => '' ) + ); + + // Should not throw any errors - empty keys are silently skipped. + $manager->pass_credentials_to_client(); + $this->assertTrue( true ); + } +}