diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 85a92e31f98df..ac59df0f5a23c 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -60,7 +60,7 @@ function _wp_connectors_mask_api_key( string $key ): string { * @param string $provider_id The WP AI client provider ID. * @return bool|null True if valid, false if invalid, null if unable to determine. */ -function _wp_connectors_is_api_key_valid( string $key, string $provider_id ): ?bool { +function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { try { $registry = AiClient::defaultRegistry(); @@ -109,55 +109,127 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca } /** - * Gets the registered connector provider settings. + * Gets the registered connector settings. * * @since 7.0.0 * @access private * - * @return array Provider settings keyed by setting name. + * @return array { + * Connector settings keyed by connector ID. + * + * @type array ...$0 { + * Data for a single connector. + * + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url and setting_name. When 'none', only method is present. + * + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * } + * } + * } */ -function _wp_connectors_get_provider_settings(): array { - $providers = array( +function _wp_connectors_get_connector_settings(): array { + $connectors = array( 'google' => array( - 'name' => 'Google', + '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', + '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', + ), ), 'anthropic' => array( - 'name' => 'Anthropic', + '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', + ), ), ); - $provider_settings = array(); - foreach ( $providers as $provider => $data ) { - $setting_name = "connectors_ai_{$provider}_api_key"; + $registry = AiClient::defaultRegistry(); - $provider_settings[ $setting_name ] = array( - 'provider' => $provider, - 'label' => sprintf( - /* translators: %s: AI provider name. */ - __( '%s API Key' ), - $data['name'] - ), - 'description' => sprintf( - /* translators: %s: AI provider name. */ - __( 'API key for the %s AI provider.' ), - $data['name'] - ), - 'mask' => '_wp_connectors_mask_api_key', - 'sanitize' => static function ( string $value ) use ( $provider ): string { - $value = sanitize_text_field( $value ); - if ( '' === $value ) { - return $value; - } - - $valid = _wp_connectors_is_api_key_valid( $value, $provider ); - return true === $valid ? $value : ''; - }, - ); + 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, + ); + } + } + + // 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 $provider_settings; + + return $connectors; } /** @@ -181,10 +253,6 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE return $response; } - if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { - return $response; - } - $fields = $request->get_param( '_fields' ); if ( ! $fields ) { return $response; @@ -201,17 +269,23 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE return $response; } - foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { + 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; + } + + $setting_name = $auth['setting_name']; if ( ! in_array( $setting_name, $requested, true ) ) { continue; } - $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); if ( '' === $real_key ) { continue; } - if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { $data[ $setting_name ] = 'invalid_key'; } } @@ -228,27 +302,45 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { - if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { - return; - } + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + $auth = $connector_data['authentication']; + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + continue; + } - foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { + $setting_name = $auth['setting_name']; register_setting( 'connectors', $setting_name, array( 'type' => 'string', - 'label' => $config['label'], - 'description' => $config['description'], + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s API Key' ), + $connector_data['name'] + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'API key for the %s AI provider.' ), + $connector_data['name'] + ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => $config['sanitize'], + 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + + $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); + return true === $valid ? $value : ''; + }, ) ); - add_filter( "option_{$setting_name}", $config['mask'] ); + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } -add_action( 'init', '_wp_register_default_connector_settings' ); +add_action( 'init', '_wp_register_default_connector_settings', 20 ); /** * Passes stored connector API keys to the WP AI client. @@ -257,19 +349,25 @@ function _wp_register_default_connector_settings(): void { * @access private */ function _wp_connectors_pass_default_keys_to_ai_client(): void { - if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { - return; - } try { $registry = AiClient::defaultRegistry(); - foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { - $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); - if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) { + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + if ( 'ai_provider' !== $connector_data['type'] ) { + continue; + } + + $auth = $connector_data['authentication']; + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + continue; + } + + $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); + if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { continue; } $registry->setProviderRequestAuthentication( - $config['provider'], + $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); } @@ -277,4 +375,42 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { wp_trigger_error( __FUNCTION__, $e->getMessage() ); } } -add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' ); +add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); + +/** + * Exposes connector settings to the connectors-wp-admin script module. + * + * @since 7.0.0 + * @access private + * + * @param array $data Existing script module data. + * @return array Script module data with connectors added. + */ +function _wp_connectors_get_connector_script_module_data( array $data ): array { + $connectors = array(); + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + $auth = $connector_data['authentication']; + $auth_out = array( 'method' => $auth['method'] ); + + if ( 'api_key' === $auth['method'] ) { + $auth_out['settingName'] = $auth['setting_name'] ?? ''; + $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + } + + $connector_out = array( + 'name' => $connector_data['name'], + 'description' => $connector_data['description'], + 'type' => $connector_data['type'], + 'authentication' => $auth_out, + ); + + if ( ! empty( $connector_data['plugin'] ) ) { + $connector_out['plugin'] = $connector_data['plugin']; + } + + $connectors[ $connector_id ] = $connector_out; + } + $data['connectors'] = $connectors; + return $data; +} +add_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' ); diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index 66e916e94876d..d6b6218278ae3 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -375,6 +375,18 @@ function _unhook_font_registration() { } tests_add_filter( 'init', '_unhook_font_registration', 1000 ); +/** + * After the init action has been run once, trying to re-register connector settings can cause + * duplicate registrations. To avoid this, unhook the connector registration functions. + * + * @since 7.0.0 + */ +function _unhook_connector_registration() { + remove_action( 'init', '_wp_register_default_connector_settings', 20 ); + remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); +} +tests_add_filter( 'init', '_unhook_connector_registration', 1000 ); + /** * Before the abilities API categories init action runs, unhook the core ability * categories registration function to prevent core categories from being registered diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php index 326ead2ed771e..9797017451e0d 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -169,4 +169,17 @@ private static function register_mock_connectors_provider(): void { private static function set_mock_provider_configured( bool $is_configured ): void { Mock_Connectors_Test_Provider_Availability::$is_configured = $is_configured; } + + /** + * Unregisters the mock provider's connector setting. + * + * Reverses the side effect of _wp_register_default_connector_settings() + * for the mock provider so that subsequent test classes start with a clean slate. + * Must be called from tear_down_after_class() after running tests. + */ + private static function unregister_mock_connector_setting(): void { + $setting_name = 'connectors_ai_mock_connectors_test_api_key'; + unregister_setting( 'connectors', $setting_name ); + remove_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + } } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php new file mode 100644 index 0000000000000..f2d0aa68ee0e1 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -0,0 +1,118 @@ +assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'openai', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + $this->assertArrayHasKey( 'mock_connectors_test', $connectors ); + $this->assertCount( 4, $connectors ); + } + + /** + * @ticket 64730 + */ + public function test_each_connector_has_required_fields() { + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertNotEmpty( $connectors, 'Connector settings should not be empty.' ); + + foreach ( $connectors as $connector_id => $connector_data ) { + $this->assertArrayHasKey( 'name', $connector_data, "Connector '{$connector_id}' is missing 'name'." ); + $this->assertIsString( $connector_data['name'], "Connector '{$connector_id}' name should be a string." ); + $this->assertNotEmpty( $connector_data['name'], "Connector '{$connector_id}' name should not be empty." ); + $this->assertArrayHasKey( 'description', $connector_data, "Connector '{$connector_id}' is missing 'description'." ); + $this->assertIsString( $connector_data['description'], "Connector '{$connector_id}' description should be a string." ); + $this->assertArrayHasKey( 'type', $connector_data, "Connector '{$connector_id}' is missing 'type'." ); + $this->assertContains( $connector_data['type'], array( 'ai_provider' ), "Connector '{$connector_id}' has unexpected type '{$connector_data['type']}'." ); + $this->assertArrayHasKey( 'authentication', $connector_data, "Connector '{$connector_id}' is missing 'authentication'." ); + $this->assertIsArray( $connector_data['authentication'], "Connector '{$connector_id}' authentication should be an array." ); + $this->assertArrayHasKey( 'method', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'method'." ); + $this->assertContains( $connector_data['authentication']['method'], array( 'api_key', 'none' ), "Connector '{$connector_id}' has unexpected authentication method." ); + } + } + + /** + * @ticket 64730 + */ + public function test_api_key_connectors_have_setting_name_and_credentials_url() { + $connectors = _wp_connectors_get_connector_settings(); + $api_key_count = 0; + + foreach ( $connectors as $connector_id => $connector_data ) { + if ( 'api_key' !== $connector_data['authentication']['method'] ) { + continue; + } + + ++$api_key_count; + + $this->assertArrayHasKey( 'setting_name', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'setting_name'." ); + $this->assertSame( + "connectors_ai_{$connector_id}_api_key", + $connector_data['authentication']['setting_name'], + "Connector '{$connector_id}' setting_name does not match expected format." + ); + $this->assertArrayHasKey( 'credentials_url', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'credentials_url'." ); + } + + $this->assertGreaterThan( 0, $api_key_count, 'At least one connector should use api_key authentication.' ); + } + + /** + * @ticket 64730 + */ + public function test_featured_provider_names_match_expected() { + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertSame( 'Google', $connectors['google']['name'] ); + $this->assertSame( 'OpenAI', $connectors['openai']['name'] ); + $this->assertSame( 'Anthropic', $connectors['anthropic']['name'] ); + } + + /** + * @ticket 64730 + */ + public function test_includes_registered_provider_from_registry() { + $connectors = _wp_connectors_get_connector_settings(); + $mock = $connectors['mock_connectors_test']; + + $this->assertSame( 'Mock Connectors Test', $mock['name'] ); + $this->assertSame( '', $mock['description'] ); + $this->assertSame( 'ai_provider', $mock['type'] ); + $this->assertSame( 'api_key', $mock['authentication']['method'] ); + $this->assertNull( $mock['authentication']['credentials_url'] ); + $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php deleted file mode 100644 index 8d49391d34d38..0000000000000 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ /dev/null @@ -1,46 +0,0 @@ -assertArrayHasKey( 'connectors_ai_google_api_key', $settings ); - $this->assertArrayHasKey( 'connectors_ai_openai_api_key', $settings ); - $this->assertArrayHasKey( 'connectors_ai_anthropic_api_key', $settings ); - $this->assertCount( 3, $settings ); - } - - /** - * @ticket 64730 - */ - public function test_each_setting_has_required_fields() { - $settings = _wp_connectors_get_provider_settings(); - $required_keys = array( 'provider', 'label', 'description', 'mask', 'sanitize' ); - - foreach ( $settings as $setting_name => $config ) { - foreach ( $required_keys as $key ) { - $this->assertArrayHasKey( $key, $config, "Setting '{$setting_name}' is missing '{$key}'." ); - } - } - } - - /** - * @ticket 64730 - */ - public function test_provider_values_match_expected() { - $settings = _wp_connectors_get_provider_settings(); - - $this->assertSame( 'google', $settings['connectors_ai_google_api_key']['provider'] ); - $this->assertSame( 'openai', $settings['connectors_ai_openai_api_key']['provider'] ); - $this->assertSame( 'anthropic', $settings['connectors_ai_anthropic_api_key']['provider'] ); - } -} diff --git a/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php b/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php index 79d4129be5103..d21b4a58695f5 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php +++ b/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php @@ -3,10 +3,10 @@ require_once dirname( __DIR__, 2 ) . '/includes/wp-ai-client-mock-provider-trait.php'; /** - * Tests for _wp_connectors_is_api_key_valid(). + * Tests for _wp_connectors_is_ai_api_key_valid(). * * @group connectors - * @covers ::_wp_connectors_is_api_key_valid + * @covers ::_wp_connectors_is_ai_api_key_valid */ class Tests_Connectors_WpConnectorsIsApiKeyValid extends WP_UnitTestCase { @@ -34,9 +34,9 @@ public function set_up() { * @ticket 64730 */ public function test_unregistered_provider_returns_null() { - $this->setExpectedIncorrectUsage( '_wp_connectors_is_api_key_valid' ); + $this->setExpectedIncorrectUsage( '_wp_connectors_is_ai_api_key_valid' ); - $result = _wp_connectors_is_api_key_valid( 'test-key', 'nonexistent_provider' ); + $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'nonexistent_provider' ); $this->assertNull( $result ); } @@ -49,7 +49,7 @@ public function test_unregistered_provider_returns_null() { public function test_configured_provider_returns_true() { self::set_mock_provider_configured( true ); - $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' ); + $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'mock_connectors_test' ); $this->assertTrue( $result ); } @@ -62,7 +62,7 @@ public function test_configured_provider_returns_true() { public function test_unconfigured_provider_returns_false() { self::set_mock_provider_configured( false ); - $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' ); + $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'mock_connectors_test' ); $this->assertFalse( $result ); }