From a6ee0ac08036163d9a0ce16c671389c900c00283 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 27 Feb 2026 14:11:30 +0100 Subject: [PATCH 01/12] Connectors: Dynamically register providers from WP AI Client registry Expand `_wp_connectors_get_provider_settings()` to fetch registered providers from the AI Client registry alongside the hardcoded featured providers. Restructure the return value to be keyed by provider ID with `name`, `description`, `credentials_url`, and nested `settings`. Filter out providers that don't use API key authentication. Update all consumer functions and tests accordingly. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 174 ++++++++++++------ .../wpConnectorsGetProviderSettings.php | 76 ++++++-- 2 files changed, 177 insertions(+), 73 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 85a92e31f98df..cdd216d159ad8 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -114,47 +114,99 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * @since 7.0.0 * @access private * - * @return array Provider settings keyed by setting name. + * @return array { + * Provider settings keyed by provider ID. + * + * @type array ...$0 { + * Data for a single provider. + * + * @type string $name The provider's display name. + * @type string $description The provider's description. + * @type string|null $credentials_url URL where users can obtain API credentials. + * @type array $settings { + * Settings keyed by setting name. + * + * @type array ...$0 { + * Data for a single setting. + * + * @type string $label The setting label. + * @type string $description The setting description. + * @type callable $mask Callback to mask the stored value. + * @type callable $sanitize Callback to sanitize the input value. + * } + * } + * } + * } */ function _wp_connectors_get_provider_settings(): array { $providers = array( 'google' => array( - 'name' => 'Google', + 'name' => 'Gemini', + 'description' => __( 'Content generation, translation, and vision with Google\'s Gemini.' ), + 'credentials_url' => 'https://aistudio.google.com/', ), 'openai' => array( - 'name' => 'OpenAI', + 'name' => 'OpenAI', + 'description' => __( 'Text, image, and code generation with GPT and DALL-E.' ), + 'credentials_url' => 'https://platform.openai.com/', ), 'anthropic' => array( - 'name' => 'Anthropic', + 'name' => 'Claude', + 'description' => __( 'Writing, research, and analysis with Claude.' ), + 'credentials_url' => 'https://console.anthropic.com/', ), ); + $registry = AiClient::defaultRegistry(); + + foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { + $provider_class_name = $registry->getProviderClassName( $provider_id ); + $provider_metadata = $provider_class_name::metadata(); + + $auth_method = $provider_metadata->getAuthenticationMethod(); + if ( null === $auth_method || ! $auth_method->isApiKey() ) { + continue; + } + + $providers[ $provider_id ] = array( + 'name' => $provider_metadata->getName(), + 'description' => '', // TODO: Retrieve description from provider metadata when available. + 'credentials_url' => $provider_metadata->getCredentialsUrl(), + ); + } + $provider_settings = array(); foreach ( $providers as $provider => $data ) { $setting_name = "connectors_ai_{$provider}_api_key"; - $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; - } + $provider_settings[ $provider ] = array( + 'name' => $data['name'], + 'description' => $data['description'], + 'credentials_url' => $data['credentials_url'], + 'settings' => array( + $setting_name => array( + '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 : ''; - }, + $valid = _wp_connectors_is_api_key_valid( $value, $provider ); + return true === $valid ? $value : ''; + }, + ), + ), ); } return $provider_settings; @@ -201,18 +253,20 @@ 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 ) { - if ( ! in_array( $setting_name, $requested, true ) ) { - continue; - } + foreach ( _wp_connectors_get_provider_settings() as $provider => $provider_data ) { + foreach ( $provider_data['settings'] as $setting_name => $config ) { + if ( ! in_array( $setting_name, $requested, true ) ) { + continue; + } - $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); - if ( '' === $real_key ) { - continue; - } + $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + if ( '' === $real_key ) { + continue; + } - if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) { - $data[ $setting_name ] = 'invalid_key'; + if ( true !== _wp_connectors_is_api_key_valid( $real_key, $provider ) ) { + $data[ $setting_name ] = 'invalid_key'; + } } } @@ -232,20 +286,22 @@ function _wp_register_default_connector_settings(): void { return; } - foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { - register_setting( - 'connectors', - $setting_name, - array( - 'type' => 'string', - 'label' => $config['label'], - 'description' => $config['description'], - 'default' => '', - 'show_in_rest' => true, - 'sanitize_callback' => $config['sanitize'], - ) - ); - add_filter( "option_{$setting_name}", $config['mask'] ); + foreach ( _wp_connectors_get_provider_settings() as $provider_data ) { + foreach ( $provider_data['settings'] as $setting_name => $config ) { + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + 'label' => $config['label'], + 'description' => $config['description'], + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => $config['sanitize'], + ) + ); + add_filter( "option_{$setting_name}", $config['mask'] ); + } } } add_action( 'init', '_wp_register_default_connector_settings' ); @@ -262,16 +318,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { } 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'] ) ) { - continue; - } + foreach ( _wp_connectors_get_provider_settings() as $provider => $provider_data ) { + foreach ( $provider_data['settings'] as $setting_name => $config ) { + $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + if ( '' === $api_key || ! $registry->hasProvider( $provider ) ) { + continue; + } - $registry->setProviderRequestAuthentication( - $config['provider'], - new ApiKeyRequestAuthentication( $api_key ) - ); + $registry->setProviderRequestAuthentication( + $provider, + new ApiKeyRequestAuthentication( $api_key ) + ); + } } } catch ( Exception $e ) { wp_trigger_error( __FUNCTION__, $e->getMessage() ); diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php index 8d49391d34d38..dabc0615bcbbd 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php @@ -1,4 +1,7 @@ assertArrayHasKey( 'google', $providers ); + $this->assertArrayHasKey( 'openai', $providers ); + $this->assertArrayHasKey( 'anthropic', $providers ); + $this->assertArrayHasKey( 'mock_connectors_test', $providers ); + $this->assertCount( 4, $providers ); + } + + /** + * @ticket 64730 + */ + public function test_each_provider_has_required_fields() { + $providers = _wp_connectors_get_provider_settings(); - $this->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 ); + foreach ( $providers as $provider_id => $provider_data ) { + $this->assertArrayHasKey( 'name', $provider_data, "Provider '{$provider_id}' is missing 'name'." ); + $this->assertArrayHasKey( 'description', $provider_data, "Provider '{$provider_id}' is missing 'description'." ); + $this->assertIsString( $provider_data['description'], "Provider '{$provider_id}' description should be a string." ); + $this->assertArrayHasKey( 'credentials_url', $provider_data, "Provider '{$provider_id}' is missing 'credentials_url'." ); + $this->assertArrayHasKey( 'settings', $provider_data, "Provider '{$provider_id}' is missing 'settings'." ); + $this->assertIsArray( $provider_data['settings'], "Provider '{$provider_id}' settings should be an array." ); + } } /** * @ticket 64730 */ public function test_each_setting_has_required_fields() { - $settings = _wp_connectors_get_provider_settings(); - $required_keys = array( 'provider', 'label', 'description', 'mask', 'sanitize' ); + $providers = _wp_connectors_get_provider_settings(); + $required_keys = array( '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}'." ); + foreach ( $providers as $provider_id => $provider_data ) { + foreach ( $provider_data['settings'] as $setting_name => $config ) { + foreach ( $required_keys as $key ) { + $this->assertArrayHasKey( $key, $config, "Setting '{$setting_name}' for provider '{$provider_id}' is missing '{$key}'." ); + } } } } @@ -36,11 +68,25 @@ public function test_each_setting_has_required_fields() { /** * @ticket 64730 */ - public function test_provider_values_match_expected() { - $settings = _wp_connectors_get_provider_settings(); + public function test_provider_names_match_expected() { + $providers = _wp_connectors_get_provider_settings(); + + $this->assertSame( 'Gemini', $providers['google']['name'] ); + $this->assertSame( 'OpenAI', $providers['openai']['name'] ); + $this->assertSame( 'Claude', $providers['anthropic']['name'] ); + } + + /** + * @ticket 64730 + */ + public function test_includes_registered_provider_from_registry() { + $providers = _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'] ); + $this->assertArrayHasKey( 'mock_connectors_test', $providers ); + $this->assertSame( 'Mock Connectors Test', $providers['mock_connectors_test']['name'] ); + $this->assertSame( '', $providers['mock_connectors_test']['description'] ); + $this->assertNull( $providers['mock_connectors_test']['credentials_url'] ); + $this->assertArrayHasKey( 'connectors_ai_mock_connectors_test_api_key', $providers['mock_connectors_test']['settings'] ); + $this->assertSame( 'Mock Connectors Test API Key', $providers['mock_connectors_test']['settings']['connectors_ai_mock_connectors_test_api_key']['label'] ); } } From 8d957b67886cba01f260c06a9f95ae30c70984a1 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 27 Feb 2026 14:54:03 +0100 Subject: [PATCH 02/12] Connectors: Improve provider merge logic and remove per-setting mask callback. Filter out empty registry values before merging so hardcoded fallbacks (e.g. credentials_url, description) are preserved when the registry doesn't provide them. Replace per-setting $config['mask'] references with direct '_wp_connectors_mask_api_key' calls since the callback is always the same. Make REST settings test resilient to dynamically registered connector providers by querying get_registered_settings(). Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 33 +++++++++++++------ .../wpConnectorsGetProviderSettings.php | 2 +- .../rest-api/rest-settings-controller.php | 11 ++++--- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index cdd216d159ad8..7a42fbbef37ac 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -131,7 +131,6 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * * @type string $label The setting label. * @type string $description The setting description. - * @type callable $mask Callback to mask the stored value. * @type callable $sanitize Callback to sanitize the input value. * } * } @@ -168,11 +167,26 @@ function _wp_connectors_get_provider_settings(): array { continue; } - $providers[ $provider_id ] = array( - 'name' => $provider_metadata->getName(), - 'description' => '', // TODO: Retrieve description from provider metadata when available. - 'credentials_url' => $provider_metadata->getCredentialsUrl(), + $registry_data = array_filter( + array( + 'name' => $provider_metadata->getName(), + 'credentials_url' => $provider_metadata->getCredentialsUrl(), + ) ); + + if ( isset( $providers[ $provider_id ] ) ) { + // Merge non-empty registry data over hardcoded fallbacks. + $providers[ $provider_id ] = array_merge( $providers[ $provider_id ], $registry_data ); + } else { + $providers[ $provider_id ] = array_merge( + array( + 'name' => '', + 'description' => '', + 'credentials_url' => null, + ), + $registry_data + ); + } } $provider_settings = array(); @@ -195,8 +209,7 @@ function _wp_connectors_get_provider_settings(): array { __( 'API key for the %s AI provider.' ), $data['name'] ), - 'mask' => '_wp_connectors_mask_api_key', - 'sanitize' => static function ( string $value ) use ( $provider ): string { + 'sanitize' => static function ( string $value ) use ( $provider ): string { $value = sanitize_text_field( $value ); if ( '' === $value ) { return $value; @@ -259,7 +272,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE 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; } @@ -300,7 +313,7 @@ function _wp_register_default_connector_settings(): void { 'sanitize_callback' => $config['sanitize'], ) ); - add_filter( "option_{$setting_name}", $config['mask'] ); + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } } @@ -320,7 +333,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { $registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_provider_settings() as $provider => $provider_data ) { foreach ( $provider_data['settings'] as $setting_name => $config ) { - $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + $api_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); if ( '' === $api_key || ! $registry->hasProvider( $provider ) ) { continue; } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php index dabc0615bcbbd..879500945cd1d 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php @@ -54,7 +54,7 @@ public function test_each_provider_has_required_fields() { */ public function test_each_setting_has_required_fields() { $providers = _wp_connectors_get_provider_settings(); - $required_keys = array( 'label', 'description', 'mask', 'sanitize' ); + $required_keys = array( 'label', 'description', 'sanitize' ); foreach ( $providers as $provider_id => $provider_data ) { foreach ( $provider_data['settings'] as $setting_name => $config ) { diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index ef9e72e6a6724..606e6a723996c 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,12 +120,15 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', - // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. - 'connectors_ai_anthropic_api_key', - 'connectors_ai_google_api_key', - 'connectors_ai_openai_api_key', + // Connectors API keys are registered dynamically in _wp_register_default_connector_settings() in wp-includes/connectors.php. ); + foreach ( get_registered_settings() as $setting_name => $setting ) { + if ( 'connectors' === $setting['group'] ) { + $expected[] = $setting_name; + } + } + if ( ! is_multisite() ) { $expected[] = 'url'; $expected[] = 'email'; From 281d5d07d833029dc4c7220bcfbc2c0f957b88f8 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 27 Feb 2026 14:54:54 +0100 Subject: [PATCH 03/12] Tests: Update REST API generated fixtures for connector provider name changes. Co-Authored-By: Claude Opus 4.6 --- tests/qunit/fixtures/wp-api-generated.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index ef59ce5ca5074..8cfdd141fbf26 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11065,8 +11065,8 @@ mockedApiResponse.Schema = { ], "args": { "connectors_ai_google_api_key": { - "title": "Google API Key", - "description": "API key for the Google AI provider.", + "title": "Gemini API Key", + "description": "API key for the Gemini AI provider.", "type": "string", "required": false }, @@ -11077,8 +11077,8 @@ mockedApiResponse.Schema = { "required": false }, "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", + "title": "Claude API Key", + "description": "API key for the Claude AI provider.", "type": "string", "required": false }, @@ -11217,6 +11217,12 @@ mockedApiResponse.Schema = { "description": "Site icon.", "type": "integer", "required": false + }, + "connectors_ai_mock_connectors_test_api_key": { + "title": "Mock Connectors Test API Key", + "description": "API key for the Mock Connectors Test AI provider.", + "type": "string", + "required": false } } } @@ -14675,5 +14681,6 @@ mockedApiResponse.settings = { "default_ping_status": "open", "default_comment_status": "open", "site_logo": null, - "site_icon": 0 + "site_icon": 0, + "connectors_ai_mock_connectors_test_api_key": "" }; From db1140f4d408dbdd0363a5c80a1ee73c1499e3af Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 2 Mar 2026 13:54:57 +0100 Subject: [PATCH 04/12] Connectors: Restructure provider data with authentication sub-object and expose to script module. Polyfills WordPress/gutenberg#76014: - Rename _wp_connectors_get_provider_settings() to _wp_connectors_get_connector_settings() with a restructured return format: type field, authentication sub-object (method, credentials_url, setting_name) replacing the nested settings array. - Include all registered providers regardless of auth method (api_key or none) instead of filtering out non-API-key providers. - Move label, description, and sanitize logic into consumer functions. - Add ucwords() fallback for providers with no name. - Add _wp_connectors_get_connector_script_module_data() to expose connector data to the connectors-wp-admin script module. - Update all consumer functions and tests to use the new structure. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 303 ++++++++++-------- .../wpConnectorsGetProviderSettings.php | 102 +++--- 2 files changed, 235 insertions(+), 170 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 7a42fbbef37ac..d1a7e87e5ecc3 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -109,120 +109,115 @@ 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 provider ID. + * Connector settings keyed by connector ID. * * @type array ...$0 { - * Data for a single provider. + * Data for a single connector. * - * @type string $name The provider's display name. - * @type string $description The provider's description. - * @type string|null $credentials_url URL where users can obtain API credentials. - * @type array $settings { - * Settings keyed by setting name. + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $type The connector type: 'ai_provider'. + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url and setting_name. When 'none', only method is present. * - * @type array ...$0 { - * Data for a single setting. - * - * @type string $label The setting label. - * @type string $description The setting description. - * @type callable $sanitize Callback to sanitize the input value. - * } + * @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' => 'Gemini', - 'description' => __( 'Content generation, translation, and vision with Google\'s Gemini.' ), - 'credentials_url' => 'https://aistudio.google.com/', + 'name' => 'Gemini', + 'description' => __( 'Content generation, translation, and vision with Google\'s Gemini.' ), + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://aistudio.google.com/api-keys', + ), ), 'openai' => array( - 'name' => 'OpenAI', - 'description' => __( 'Text, image, and code generation with GPT and DALL-E.' ), - 'credentials_url' => 'https://platform.openai.com/', + 'name' => 'OpenAI', + 'description' => __( 'Text, image, and code generation with GPT and DALL-E.' ), + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.openai.com/api-keys', + ), ), 'anthropic' => array( - 'name' => 'Claude', - 'description' => __( 'Writing, research, and analysis with Claude.' ), - 'credentials_url' => 'https://console.anthropic.com/', + 'name' => 'Claude', + 'description' => __( 'Writing, research, and analysis with Claude.' ), + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.claude.com/settings/keys', + ), ), ); $registry = AiClient::defaultRegistry(); - foreach ( $registry->getRegisteredProviderIds() as $provider_id ) { - $provider_class_name = $registry->getProviderClassName( $provider_id ); + foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $registry->getProviderClassName( $connector_id ); $provider_metadata = $provider_class_name::metadata(); $auth_method = $provider_metadata->getAuthenticationMethod(); - if ( null === $auth_method || ! $auth_method->isApiKey() ) { - continue; + $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' ); } - $registry_data = array_filter( - array( - 'name' => $provider_metadata->getName(), - 'credentials_url' => $provider_metadata->getCredentialsUrl(), - ) - ); + $name = $provider_metadata->getName(); + $description = method_exists( $provider_metadata, 'getDescription' ) ? $provider_metadata->getDescription() : null; - if ( isset( $providers[ $provider_id ] ) ) { - // Merge non-empty registry data over hardcoded fallbacks. - $providers[ $provider_id ] = array_merge( $providers[ $provider_id ], $registry_data ); + 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 { - $providers[ $provider_id ] = array_merge( - array( - 'name' => '', - 'description' => '', - 'credentials_url' => null, - ), - $registry_data + $connectors[ $connector_id ] = array( + 'name' => $name ? $name : ucwords( $connector_id ), + 'description' => $description ? $description : '', + 'type' => 'ai_provider', + 'authentication' => $authentication, ); } } - $provider_settings = array(); - foreach ( $providers as $provider => $data ) { - $setting_name = "connectors_ai_{$provider}_api_key"; - - $provider_settings[ $provider ] = array( - 'name' => $data['name'], - 'description' => $data['description'], - 'credentials_url' => $data['credentials_url'], - 'settings' => array( - $setting_name => array( - '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'] - ), - '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 : ''; - }, - ), - ), - ); + // 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; } /** @@ -246,10 +241,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; @@ -266,20 +257,24 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE return $response; } - foreach ( _wp_connectors_get_provider_settings() as $provider => $provider_data ) { - foreach ( $provider_data['settings'] as $setting_name => $config ) { - if ( ! in_array( $setting_name, $requested, true ) ) { - continue; - } + 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; + } - $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $real_key ) { - continue; - } + $setting_name = $auth['setting_name']; + if ( ! in_array( $setting_name, $requested, true ) ) { + continue; + } - if ( true !== _wp_connectors_is_api_key_valid( $real_key, $provider ) ) { - $data[ $setting_name ] = 'invalid_key'; - } + $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, $connector_id ) ) { + $data[ $setting_name ] = 'invalid_key'; } } @@ -295,26 +290,42 @@ 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_provider_settings() as $provider_data ) { - foreach ( $provider_data['settings'] as $setting_name => $config ) { - register_setting( - 'connectors', - $setting_name, - array( - 'type' => 'string', - 'label' => $config['label'], - 'description' => $config['description'], - 'default' => '', - 'show_in_rest' => true, - 'sanitize_callback' => $config['sanitize'], - ) - ); - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); + 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; } + + $setting_name = $auth['setting_name']; + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + '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' => static function ( string $value ) use ( $connector_id ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + + $valid = _wp_connectors_is_api_key_valid( $value, $connector_id ); + return true === $valid ? $value : ''; + }, + ) + ); + add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } add_action( 'init', '_wp_register_default_connector_settings' ); @@ -326,26 +337,62 @@ 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 $provider => $provider_data ) { - foreach ( $provider_data['settings'] as $setting_name => $config ) { - $api_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $provider ) ) { - continue; - } - - $registry->setProviderRequestAuthentication( - $provider, - new ApiKeyRequestAuthentication( $api_key ) - ); + 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( + $connector_id, + new ApiKeyRequestAuthentication( $api_key ) + ); } } catch ( Exception $e ) { wp_trigger_error( __FUNCTION__, $e->getMessage() ); } } add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' ); + +/** + * 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; + } + + $connectors[ $connector_id ] = array( + 'name' => $connector_data['name'], + 'description' => $connector_data['description'], + 'type' => $connector_data['type'], + 'authentication' => $auth_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/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php index 879500945cd1d..4acf9786eac4f 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php @@ -3,10 +3,10 @@ require_once dirname( __DIR__, 2 ) . '/includes/wp-ai-client-mock-provider-trait.php'; /** - * Tests for _wp_connectors_get_provider_settings(). + * Tests for _wp_connectors_get_connector_settings(). * * @group connectors - * @covers ::_wp_connectors_get_provider_settings + * @covers ::_wp_connectors_get_connector_settings */ class Tests_Connectors_WpConnectorsGetProviderSettings extends WP_UnitTestCase { @@ -23,70 +23,88 @@ public static function set_up_before_class() { /** * @ticket 64730 */ - public function test_returns_expected_provider_keys() { - $providers = _wp_connectors_get_provider_settings(); - - $this->assertArrayHasKey( 'google', $providers ); - $this->assertArrayHasKey( 'openai', $providers ); - $this->assertArrayHasKey( 'anthropic', $providers ); - $this->assertArrayHasKey( 'mock_connectors_test', $providers ); - $this->assertCount( 4, $providers ); + public function test_returns_expected_connector_keys() { + $connectors = _wp_connectors_get_connector_settings(); + + $this->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_provider_has_required_fields() { - $providers = _wp_connectors_get_provider_settings(); - - foreach ( $providers as $provider_id => $provider_data ) { - $this->assertArrayHasKey( 'name', $provider_data, "Provider '{$provider_id}' is missing 'name'." ); - $this->assertArrayHasKey( 'description', $provider_data, "Provider '{$provider_id}' is missing 'description'." ); - $this->assertIsString( $provider_data['description'], "Provider '{$provider_id}' description should be a string." ); - $this->assertArrayHasKey( 'credentials_url', $provider_data, "Provider '{$provider_id}' is missing 'credentials_url'." ); - $this->assertArrayHasKey( 'settings', $provider_data, "Provider '{$provider_id}' is missing 'settings'." ); - $this->assertIsArray( $provider_data['settings'], "Provider '{$provider_id}' settings should be an array." ); + 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_each_setting_has_required_fields() { - $providers = _wp_connectors_get_provider_settings(); - $required_keys = array( 'label', 'description', 'sanitize' ); - - foreach ( $providers as $provider_id => $provider_data ) { - foreach ( $provider_data['settings'] as $setting_name => $config ) { - foreach ( $required_keys as $key ) { - $this->assertArrayHasKey( $key, $config, "Setting '{$setting_name}' for provider '{$provider_id}' is missing '{$key}'." ); - } + 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_provider_names_match_expected() { - $providers = _wp_connectors_get_provider_settings(); + public function test_featured_provider_names_match_expected() { + $connectors = _wp_connectors_get_connector_settings(); - $this->assertSame( 'Gemini', $providers['google']['name'] ); - $this->assertSame( 'OpenAI', $providers['openai']['name'] ); - $this->assertSame( 'Claude', $providers['anthropic']['name'] ); + $this->assertSame( 'Gemini', $connectors['google']['name'] ); + $this->assertSame( 'OpenAI', $connectors['openai']['name'] ); + $this->assertSame( 'Claude', $connectors['anthropic']['name'] ); } /** * @ticket 64730 */ public function test_includes_registered_provider_from_registry() { - $providers = _wp_connectors_get_provider_settings(); - - $this->assertArrayHasKey( 'mock_connectors_test', $providers ); - $this->assertSame( 'Mock Connectors Test', $providers['mock_connectors_test']['name'] ); - $this->assertSame( '', $providers['mock_connectors_test']['description'] ); - $this->assertNull( $providers['mock_connectors_test']['credentials_url'] ); - $this->assertArrayHasKey( 'connectors_ai_mock_connectors_test_api_key', $providers['mock_connectors_test']['settings'] ); - $this->assertSame( 'Mock Connectors Test API Key', $providers['mock_connectors_test']['settings']['connectors_ai_mock_connectors_test_api_key']['label'] ); + $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'] ); } } From 3c8dad3c404553a6ef4fcd6c3841c99c72f59835 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 2 Mar 2026 14:19:44 +0100 Subject: [PATCH 05/12] Connectors: Remove method_exists check for getDescription. getDescription() is now part of the provider metadata API contract, so the defensive method_exists() guard is no longer needed. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index d1a7e87e5ecc3..394465710f580 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -185,7 +185,7 @@ function _wp_connectors_get_connector_settings(): array { } $name = $provider_metadata->getName(); - $description = method_exists( $provider_metadata, 'getDescription' ) ? $provider_metadata->getDescription() : null; + $description = $provider_metadata->getDescription(); if ( isset( $connectors[ $connector_id ] ) ) { // Override fields with non-empty registry values. From 0186161a538251e20e4009aa050dd304c1910416 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 12:36:15 +0100 Subject: [PATCH 06/12] Connectors: Add plugin data, rename validation function, and update provider names. - Add plugin sub-object with WordPress.org slugs to featured connector definitions for install/activate UI support. - Expose plugin data in script module output for the frontend. - Rename _wp_connectors_is_api_key_valid to _wp_connectors_is_ai_api_key_valid to reflect AI-provider scope. - Update provider names and descriptions to match Gutenberg PR #76014. - Add type check for 'ai_provider' in REST validation function. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 40 ++++++++++++++----- .../wpConnectorsGetProviderSettings.php | 4 +- .../connectors/wpConnectorsIsApiKeyValid.php | 12 +++--- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 394465710f580..eb5b8dc313bf3 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(); @@ -122,7 +122,10 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * * @type string $name The connector's display name. * @type string $description The connector's description. - * @type string $type The connector type: 'ai_provider'. + * @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. @@ -137,9 +140,12 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca function _wp_connectors_get_connector_settings(): array { $connectors = array( 'google' => array( - 'name' => 'Gemini', - 'description' => __( 'Content generation, translation, and vision with Google\'s Gemini.' ), + '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', @@ -147,17 +153,23 @@ function _wp_connectors_get_connector_settings(): array { ), 'openai' => array( 'name' => 'OpenAI', - 'description' => __( 'Text, image, and code generation with GPT and DALL-E.' ), + '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' => 'Claude', - 'description' => __( 'Writing, research, and analysis with Claude.' ), + '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', @@ -259,7 +271,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'ai_provider' !== $connector_data['type'] ||'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } @@ -273,7 +285,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE continue; } - if ( true !== _wp_connectors_is_api_key_valid( $real_key, $connector_id ) ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { $data[ $setting_name ] = 'invalid_key'; } } @@ -320,7 +332,7 @@ function _wp_register_default_connector_settings(): void { return $value; } - $valid = _wp_connectors_is_api_key_valid( $value, $connector_id ); + $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); return true === $valid ? $value : ''; }, ) @@ -385,12 +397,18 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; } - $connectors[ $connector_id ] = array( + $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; diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php index 4acf9786eac4f..d51767b6805eb 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php @@ -88,9 +88,9 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url() public function test_featured_provider_names_match_expected() { $connectors = _wp_connectors_get_connector_settings(); - $this->assertSame( 'Gemini', $connectors['google']['name'] ); + $this->assertSame( 'Google', $connectors['google']['name'] ); $this->assertSame( 'OpenAI', $connectors['openai']['name'] ); - $this->assertSame( 'Claude', $connectors['anthropic']['name'] ); + $this->assertSame( 'Anthropic', $connectors['anthropic']['name'] ); } /** 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 ); } From 3d728947fd2fb080637edcdd0edae8122e537b24 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 12:40:03 +0100 Subject: [PATCH 07/12] Tests: Rename connector settings test class and file to match function. Renames Tests_Connectors_WpConnectorsGetProviderSettings to Tests_Connectors_WpConnectorsGetConnectorSettings and the file from wpConnectorsGetProviderSettings.php to wpConnectorsGetConnectorSettings.php to match the renamed _wp_connectors_get_connector_settings() function. Co-Authored-By: Claude Opus 4.6 --- ...roviderSettings.php => wpConnectorsGetConnectorSettings.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/phpunit/tests/connectors/{wpConnectorsGetProviderSettings.php => wpConnectorsGetConnectorSettings.php} (98%) diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php similarity index 98% rename from tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php rename to tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index d51767b6805eb..35b5a85818fea 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -8,7 +8,7 @@ * @group connectors * @covers ::_wp_connectors_get_connector_settings */ -class Tests_Connectors_WpConnectorsGetProviderSettings extends WP_UnitTestCase { +class Tests_Connectors_WpConnectorsGetConnectorSettings extends WP_UnitTestCase { use WP_AI_Client_Mock_Provider_Trait; From a67e433279f17561ff645b21a82c8a5794c9cd45 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 12:44:52 +0100 Subject: [PATCH 08/12] Connectors: Fix missing space after || operator in REST validation. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index eb5b8dc313bf3..4aad2073fdf1a 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -271,7 +271,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE 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'] ) ) { + if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } From 4e98cb88fb3f05f769a4c1acec283285482caf27 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 12:50:12 +0100 Subject: [PATCH 09/12] Tests: Update REST API generated fixtures for connector provider name changes. Co-Authored-By: Claude Opus 4.6 --- tests/qunit/fixtures/wp-api-generated.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 8cfdd141fbf26..ef62618b9982d 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11065,8 +11065,8 @@ mockedApiResponse.Schema = { ], "args": { "connectors_ai_google_api_key": { - "title": "Gemini API Key", - "description": "API key for the Gemini AI provider.", + "title": "Google API Key", + "description": "API key for the Google AI provider.", "type": "string", "required": false }, @@ -11077,8 +11077,8 @@ mockedApiResponse.Schema = { "required": false }, "connectors_ai_anthropic_api_key": { - "title": "Claude API Key", - "description": "API key for the Claude AI provider.", + "title": "Anthropic API Key", + "description": "API key for the Anthropic AI provider.", "type": "string", "required": false }, From 0238103dc997a833ea955b8d3231fce59c4fb443 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 13:13:22 +0100 Subject: [PATCH 10/12] Tests: Unregister mock connector setting after tests and remove it from fixtures. Co-Authored-By: Claude Opus 4.6 --- .../includes/wp-ai-client-mock-provider-trait.php | 13 +++++++++++++ .../connectors/wpConnectorsGetConnectorSettings.php | 8 ++++++++ .../tests/rest-api/rest-settings-controller.php | 11 ++++------- tests/qunit/fixtures/wp-api-generated.js | 9 +-------- 4 files changed, 26 insertions(+), 15 deletions(-) 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 index 35b5a85818fea..f2d0aa68ee0e1 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -20,6 +20,14 @@ public static function set_up_before_class() { self::register_mock_connectors_provider(); } + /** + * Unregisters the mock provider setting added by `init`. + */ + public static function tear_down_after_class() { + self::unregister_mock_connector_setting(); + parent::tear_down_after_class(); + } + /** * @ticket 64730 */ diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index 606e6a723996c..ef9e72e6a6724 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,15 +120,12 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', - // Connectors API keys are registered dynamically in _wp_register_default_connector_settings() in wp-includes/connectors.php. + // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. + 'connectors_ai_anthropic_api_key', + 'connectors_ai_google_api_key', + 'connectors_ai_openai_api_key', ); - foreach ( get_registered_settings() as $setting_name => $setting ) { - if ( 'connectors' === $setting['group'] ) { - $expected[] = $setting_name; - } - } - if ( ! is_multisite() ) { $expected[] = 'url'; $expected[] = 'email'; diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index ef62618b9982d..ef59ce5ca5074 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11217,12 +11217,6 @@ mockedApiResponse.Schema = { "description": "Site icon.", "type": "integer", "required": false - }, - "connectors_ai_mock_connectors_test_api_key": { - "title": "Mock Connectors Test API Key", - "description": "API key for the Mock Connectors Test AI provider.", - "type": "string", - "required": false } } } @@ -14681,6 +14675,5 @@ mockedApiResponse.settings = { "default_ping_status": "open", "default_comment_status": "open", "site_logo": null, - "site_icon": 0, - "connectors_ai_mock_connectors_test_api_key": "" + "site_icon": 0 }; From e06403b3801da18e442df2b7d1c7255f1ff06187 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 13:18:35 +0100 Subject: [PATCH 11/12] Connectors: Run settings registration and key passing on init at priority 20. This ensures plugins registering AI providers at default priority have completed before connector settings are built from the registry. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 4aad2073fdf1a..ac59df0f5a23c 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -340,7 +340,7 @@ function _wp_register_default_connector_settings(): void { 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. @@ -375,7 +375,7 @@ 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. From 3c03732b77712112cbdaa4e0e2d45c0e749ea350 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 3 Mar 2026 13:40:04 +0100 Subject: [PATCH 12/12] Tests: Unhook connector registration to prevent duplicate settings on re-init. Co-Authored-By: Claude Opus 4.6 --- tests/phpunit/includes/functions.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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