Make WordPress Core

Changeset 61824


Ignore:
Timestamp:
03/04/2026 12:48:48 PM (6 months ago)
Author:
gziolo
Message:

Connectors: Dynamically register providers from WP AI Client registry.

Replaces _wp_connectors_get_provider_settings() with
_wp_connectors_get_connector_settings() that returns a richer structure keyed
by connector ID, including name, description, type, plugin slug, and an
authentication sub-object (method, credentials_url, setting_name).

The new function merges hardcoded defaults for featured providers (Anthropic,
Google, OpenAI) with metadata from the WP AI Client registry, allowing
dynamically registered providers to appear alongside built-in ones. Providers
are sorted alphabetically with ksort().

Additionally:

  • Renames _wp_connectors_is_api_key_valid() to _wp_connectors_is_ai_api_key_valid().
  • Adds _wp_connectors_get_connector_script_module_data() to expose connector settings to the connectors-wp-admin script module.
  • Includes plugin slug data for featured connectors to support install/activate UI.
  • Removes redundant class_exists checks for AiClient.
  • Runs init hooks at priority 20 so provider plugins registered at default priority are available.
  • Unhooks connector registration during tests to prevent duplicate registrations.

Synced from https://github.com/WordPress/gutenberg/pull/76014.
Developed in https://github.com/WordPress/wordpress-develop/pull/11080.

Follow-up to [61749].

Props gziolo, jorgefilipecosta, justlevine, flixos90, ellatrix.
Fixes #64730.

Location:
trunk
Files:
1 added
1 deleted
5 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/connectors.php

    r61749 r61824  
    6161 * @return bool|null True if valid, false if invalid, null if unable to determine.
    6262 */
    63 function _wp_connectors_is_api_key_valid( string $key, string $provider_id ): ?bool {
     63function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
    6464        try {
    6565                $registry = AiClient::defaultRegistry();
     
    110110
    111111/**
    112  * Gets the registered connector provider settings.
    113  *
    114  * @since 7.0.0
    115  * @access private
    116  *
    117  * @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name.
    118  */
    119 function _wp_connectors_get_provider_settings(): array {
    120         $providers = array(
     112 * Gets the registered connector settings.
     113 *
     114 * @since 7.0.0
     115 * @access private
     116 *
     117 * @return array {
     118 *     Connector settings keyed by connector ID.
     119 *
     120 *     @type array ...$0 {
     121 *         Data for a single connector.
     122 *
     123 *         @type string $name           The connector's display name.
     124 *         @type string $description    The connector's description.
     125 *         @type string $type           The connector type. Currently, only 'ai_provider' is supported.
     126 *         @type array  $plugin         Optional. Plugin data for install/activate UI.
     127 *             @type string $slug       The WordPress.org plugin slug.
     128 *         }
     129 *         @type array  $authentication {
     130 *             Authentication configuration. When method is 'api_key', includes
     131 *             credentials_url and setting_name. When 'none', only method is present.
     132 *
     133 *             @type string      $method          The authentication method: 'api_key' or 'none'.
     134 *             @type string|null $credentials_url Optional. URL where users can obtain API credentials.
     135 *             @type string      $setting_name    Optional. The setting name for the API key.
     136 *         }
     137 *     }
     138 * }
     139 */
     140function _wp_connectors_get_connector_settings(): array {
     141        $connectors = array(
     142                'anthropic' => array(
     143                        'name'           => 'Anthropic',
     144                        'description'    => __( 'Text generation with Claude.' ),
     145                        'type'           => 'ai_provider',
     146                        'plugin'         => array(
     147                                'slug' => 'ai-provider-for-anthropic',
     148                        ),
     149                        'authentication' => array(
     150                                'method'          => 'api_key',
     151                                'credentials_url' => 'https://platform.claude.com/settings/keys',
     152                        ),
     153                ),
    121154                'google'    => array(
    122                         'name' => 'Google',
     155                        'name'           => 'Google',
     156                        'description'    => __( 'Text and image generation with Gemini and Imagen.' ),
     157                        'type'           => 'ai_provider',
     158                        'plugin'         => array(
     159                                'slug' => 'ai-provider-for-google',
     160                        ),
     161                        'authentication' => array(
     162                                'method'          => 'api_key',
     163                                'credentials_url' => 'https://aistudio.google.com/api-keys',
     164                        ),
    123165                ),
    124166                'openai'    => array(
    125                         'name' => 'OpenAI',
    126                 ),
    127                 'anthropic' => array(
    128                         'name' => 'Anthropic',
     167                        'name'           => 'OpenAI',
     168                        'description'    => __( 'Text and image generation with GPT and Dall-E.' ),
     169                        'type'           => 'ai_provider',
     170                        'plugin'         => array(
     171                                'slug' => 'ai-provider-for-openai',
     172                        ),
     173                        'authentication' => array(
     174                                'method'          => 'api_key',
     175                                'credentials_url' => 'https://platform.openai.com/api-keys',
     176                        ),
    129177                ),
    130178        );
    131179
    132         $provider_settings = array();
    133         foreach ( $providers as $provider => $data ) {
    134                 $setting_name = "connectors_ai_{$provider}_api_key";
    135 
    136                 $provider_settings[ $setting_name ] = array(
    137                         'provider'    => $provider,
    138                         'label'       => sprintf(
    139                                 /* translators: %s: AI provider name. */
    140                                 __( '%s API Key' ),
    141                                 $data['name']
    142                         ),
    143                         'description' => sprintf(
    144                                 /* translators: %s: AI provider name. */
    145                                 __( 'API key for the %s AI provider.' ),
    146                                 $data['name']
    147                         ),
    148                         'mask'        => '_wp_connectors_mask_api_key',
    149                         'sanitize'    => static function ( string $value ) use ( $provider ): string {
    150                                 $value = sanitize_text_field( $value );
    151                                 if ( '' === $value ) {
    152                                         return $value;
    153                                 }
    154 
    155                                 $valid = _wp_connectors_is_api_key_valid( $value, $provider );
    156                                 return true === $valid ? $value : '';
    157                         },
    158                 );
    159         }
    160         return $provider_settings;
     180        $registry = AiClient::defaultRegistry();
     181
     182        foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
     183                $provider_class_name = $registry->getProviderClassName( $connector_id );
     184                $provider_metadata   = $provider_class_name::metadata();
     185
     186                $auth_method = $provider_metadata->getAuthenticationMethod();
     187                $is_api_key  = null !== $auth_method && $auth_method->isApiKey();
     188
     189                if ( $is_api_key ) {
     190                        $credentials_url = $provider_metadata->getCredentialsUrl();
     191                        $authentication  = array(
     192                                'method'          => 'api_key',
     193                                'credentials_url' => $credentials_url ? $credentials_url : null,
     194                        );
     195                } else {
     196                        $authentication = array( 'method' => 'none' );
     197                }
     198
     199                $name        = $provider_metadata->getName();
     200                $description = $provider_metadata->getDescription();
     201
     202                if ( isset( $connectors[ $connector_id ] ) ) {
     203                        // Override fields with non-empty registry values.
     204                        if ( $name ) {
     205                                $connectors[ $connector_id ]['name'] = $name;
     206                        }
     207                        if ( $description ) {
     208                                $connectors[ $connector_id ]['description'] = $description;
     209                        }
     210                        // Always update auth method; keep existing credentials_url as fallback.
     211                        $connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
     212                        if ( ! empty( $authentication['credentials_url'] ) ) {
     213                                $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
     214                        }
     215                } else {
     216                        $connectors[ $connector_id ] = array(
     217                                'name'           => $name ? $name : ucwords( $connector_id ),
     218                                'description'    => $description ? $description : '',
     219                                'type'           => 'ai_provider',
     220                                'authentication' => $authentication,
     221                        );
     222                }
     223        }
     224
     225        ksort( $connectors );
     226
     227        // Add setting_name for connectors that use API key authentication.
     228        foreach ( $connectors as $connector_id => $connector ) {
     229                if ( 'api_key' === $connector['authentication']['method'] ) {
     230                        $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";
     231                }
     232        }
     233
     234        return $connectors;
    161235}
    162236
     
    182256        }
    183257
    184         if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
    185                 return $response;
    186         }
    187 
    188258        $fields = $request->get_param( '_fields' );
    189259        if ( ! $fields ) {
     
    202272        }
    203273
    204         foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
     274        foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
     275                $auth = $connector_data['authentication'];
     276                if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
     277                        continue;
     278                }
     279
     280                $setting_name = $auth['setting_name'];
    205281                if ( ! in_array( $setting_name, $requested, true ) ) {
    206282                        continue;
    207283                }
    208284
    209                 $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
     285                $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
    210286                if ( '' === $real_key ) {
    211287                        continue;
    212288                }
    213289
    214                 if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
     290                if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
    215291                        $data[ $setting_name ] = 'invalid_key';
    216292                }
     
    229305 */
    230306function _wp_register_default_connector_settings(): void {
    231         if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
    232                 return;
    233         }
    234 
    235         foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
     307        foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
     308                $auth = $connector_data['authentication'];
     309                if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
     310                        continue;
     311                }
     312
     313                $setting_name = $auth['setting_name'];
    236314                register_setting(
    237315                        'connectors',
     
    239317                        array(
    240318                                'type'              => 'string',
    241                                 'label'             => $config['label'],
    242                                 'description'       => $config['description'],
     319                                'label'             => sprintf(
     320                                        /* translators: %s: AI provider name. */
     321                                        __( '%s API Key' ),
     322                                        $connector_data['name']
     323                                ),
     324                                'description'       => sprintf(
     325                                        /* translators: %s: AI provider name. */
     326                                        __( 'API key for the %s AI provider.' ),
     327                                        $connector_data['name']
     328                                ),
    243329                                'default'           => '',
    244330                                'show_in_rest'      => true,
    245                                 'sanitize_callback' => $config['sanitize'],
     331                                'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
     332                                        $value = sanitize_text_field( $value );
     333                                        if ( '' === $value ) {
     334                                                return $value;
     335                                        }
     336
     337                                        $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
     338                                        return true === $valid ? $value : '';
     339                                },
    246340                        )
    247341                );
    248                 add_filter( "option_{$setting_name}", $config['mask'] );
    249         }
    250 }
    251 add_action( 'init', '_wp_register_default_connector_settings' );
     342                add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
     343        }
     344}
     345add_action( 'init', '_wp_register_default_connector_settings', 20 );
    252346
    253347/**
     
    258352 */
    259353function _wp_connectors_pass_default_keys_to_ai_client(): void {
    260         if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
    261                 return;
    262         }
    263354        try {
    264355                $registry = AiClient::defaultRegistry();
    265                 foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
    266                         $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
    267                         if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) {
     356                foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
     357                        if ( 'ai_provider' !== $connector_data['type'] ) {
    268358                                continue;
    269359                        }
    270360
     361                        $auth = $connector_data['authentication'];
     362                        if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
     363                                continue;
     364                        }
     365
     366                        $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
     367                        if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
     368                                continue;
     369                        }
     370
    271371                        $registry->setProviderRequestAuthentication(
    272                                 $config['provider'],
     372                                $connector_id,
    273373                                new ApiKeyRequestAuthentication( $api_key )
    274374                        );
     
    278378        }
    279379}
    280 add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );
     380add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
     381
     382/**
     383 * Exposes connector settings to the connectors-wp-admin script module.
     384 *
     385 * @since 7.0.0
     386 * @access private
     387 *
     388 * @param array $data Existing script module data.
     389 * @return array Script module data with connectors added.
     390 */
     391function _wp_connectors_get_connector_script_module_data( array $data ): array {
     392        $connectors = array();
     393        foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
     394                $auth     = $connector_data['authentication'];
     395                $auth_out = array( 'method' => $auth['method'] );
     396
     397                if ( 'api_key' === $auth['method'] ) {
     398                        $auth_out['settingName']    = $auth['setting_name'] ?? '';
     399                        $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
     400                }
     401
     402                $connector_out = array(
     403                        'name'           => $connector_data['name'],
     404                        'description'    => $connector_data['description'],
     405                        'type'           => $connector_data['type'],
     406                        'authentication' => $auth_out,
     407                );
     408
     409                if ( ! empty( $connector_data['plugin'] ) ) {
     410                        $connector_out['plugin'] = $connector_data['plugin'];
     411                }
     412
     413                $connectors[ $connector_id ] = $connector_out;
     414        }
     415        $data['connectors'] = $connectors;
     416        return $data;
     417}
     418add_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
  • trunk/tests/phpunit/includes/functions.php

    r61083 r61824  
    377377
    378378/**
     379 * After the init action has been run once, trying to re-register connector settings can cause
     380 * duplicate registrations. To avoid this, unhook the connector registration functions.
     381 *
     382 * @since 7.0.0
     383 */
     384function _unhook_connector_registration() {
     385        remove_action( 'init', '_wp_register_default_connector_settings', 20 );
     386        remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
     387}
     388tests_add_filter( 'init', '_unhook_connector_registration', 1000 );
     389
     390/**
    379391 * Before the abilities API categories init action runs, unhook the core ability
    380392 * categories registration function to prevent core categories from being registered
  • trunk/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php

    r61749 r61824  
    170170                Mock_Connectors_Test_Provider_Availability::$is_configured = $is_configured;
    171171        }
     172
     173        /**
     174         * Unregisters the mock provider's connector setting.
     175         *
     176         * Reverses the side effect of _wp_register_default_connector_settings()
     177         * for the mock provider so that subsequent test classes start with a clean slate.
     178         * Must be called from tear_down_after_class() after running tests.
     179         */
     180        private static function unregister_mock_connector_setting(): void {
     181                $setting_name = 'connectors_ai_mock_connectors_test_api_key';
     182                unregister_setting( 'connectors', $setting_name );
     183                remove_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
     184        }
    172185}
  • trunk/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php

    r61749 r61824  
    44
    55/**
    6  * Tests for _wp_connectors_is_api_key_valid().
     6 * Tests for _wp_connectors_is_ai_api_key_valid().
    77 *
    88 * @group connectors
    9  * @covers ::_wp_connectors_is_api_key_valid
     9 * @covers ::_wp_connectors_is_ai_api_key_valid
    1010 */
    1111class Tests_Connectors_WpConnectorsIsApiKeyValid extends WP_UnitTestCase {
     
    3535         */
    3636        public function test_unregistered_provider_returns_null() {
    37                 $this->setExpectedIncorrectUsage( '_wp_connectors_is_api_key_valid' );
     37                $this->setExpectedIncorrectUsage( '_wp_connectors_is_ai_api_key_valid' );
    3838
    39                 $result = _wp_connectors_is_api_key_valid( 'test-key', 'nonexistent_provider' );
     39                $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'nonexistent_provider' );
    4040
    4141                $this->assertNull( $result );
     
    5050                self::set_mock_provider_configured( true );
    5151
    52                 $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' );
     52                $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'mock_connectors_test' );
    5353
    5454                $this->assertTrue( $result );
     
    6363                self::set_mock_provider_configured( false );
    6464
    65                 $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' );
     65                $result = _wp_connectors_is_ai_api_key_valid( 'test-key', 'mock_connectors_test' );
    6666
    6767                $this->assertFalse( $result );
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r61809 r61824  
    1106611066                    ],
    1106711067                    "args": {
     11068                        "connectors_ai_anthropic_api_key": {
     11069                            "title": "Anthropic API Key",
     11070                            "description": "API key for the Anthropic AI provider.",
     11071                            "type": "string",
     11072                            "required": false
     11073                        },
    1106811074                        "connectors_ai_google_api_key": {
    1106911075                            "title": "Google API Key",
     
    1107511081                            "title": "OpenAI API Key",
    1107611082                            "description": "API key for the OpenAI AI provider.",
    11077                             "type": "string",
    11078                             "required": false
    11079                         },
    11080                         "connectors_ai_anthropic_api_key": {
    11081                             "title": "Anthropic API Key",
    11082                             "description": "API key for the Anthropic AI provider.",
    1108311083                            "type": "string",
    1108411084                            "required": false
     
    1465414654
    1465514655mockedApiResponse.settings = {
     14656    "connectors_ai_anthropic_api_key": "",
    1465614657    "connectors_ai_google_api_key": "",
    1465714658    "connectors_ai_openai_api_key": "",
    14658     "connectors_ai_anthropic_api_key": "",
    1465914659    "title": "Test Blog",
    1466014660    "description": "",
Note: See TracChangeset for help on using the changeset viewer.