close

Make WordPress Core

Changeset 61824


Ignore:
Timestamp:
03/04/2026 12:48:48 PM (9 days 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.