Make WordPress Core

Changeset 61943


Ignore:
Timestamp:
03/11/2026 04:10:06 PM (5 months ago)
Author:
gziolo
Message:

Connectors: Add connector registry for extensibility

Introduces WP_Connector_Registry class and a wp_connectors_init action hook so plugins can register their own connectors alongside the built-in defaults (Anthropic, Google, OpenAI).

Key changes:

  • WP_Connector_Registry — A final singleton class managing connector registration and lookup, with validation for IDs, required fields, and authentication methods.
  • wp_connectors_init action — Fired during init after built-in connectors are registered. Passes the registry instance so plugins call $registry->register() directly.
  • _wp_connectors_init() — Private function that creates the registry, merges hardcoded defaults with AI Client registry data, registers them, then fires the action.
  • Public read-only functions — wp_is_connector_registered(), wp_get_connector(), wp_get_connectors() for querying the registry after initialization.
  • Logo URL support — Connectors can include an optional logo_url field resolved from plugin directories via _wp_connectors_resolve_ai_provider_logo_url().
  • Timing guards — set_instance() rejects calls after init completes. Registration is only possible during wp_connectors_init.
  • Connector API key settings are now only registered when the provider exists in the AI Client registry.
  • Refactors _wp_connectors_get_connector_settings() to read from the registry via wp_get_connectors().

Developed in https://github.com/WordPress/wordpress-develop/pull/11175

Props gziolo, flixos90, mukesh27, westonruter.
Fixes #64791.

Location:
trunk
Files:
4 added
7 edited

Legend:

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

    r61825 r61943  
    1111use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
    1212
    13 
    14 /**
    15  * Masks an API key, showing only the last 4 characters.
    16  *
    17  * @since 7.0.0
    18  * @access private
    19  *
    20  * @param string $key The API key to mask.
    21  * @return string The masked key, e.g. "************fj39".
    22  */
    23 function _wp_connectors_mask_api_key( string $key ): string {
    24         if ( strlen( $key ) <= 4 ) {
    25                 return $key;
    26         }
    27 
    28         return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
    29 }
    30 
    31 /**
    32  * Checks whether an API key is valid for a given provider.
    33  *
    34  * @since 7.0.0
    35  * @access private
    36  *
    37  * @param string $key         The API key to check.
    38  * @param string $provider_id The WP AI client provider ID.
    39  * @return bool|null True if valid, false if invalid, null if unable to determine.
    40  */
    41 function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
    42         try {
    43                 $registry = AiClient::defaultRegistry();
    44 
    45                 if ( ! $registry->hasProvider( $provider_id ) ) {
    46                         _doing_it_wrong(
    47                                 __FUNCTION__,
    48                                 sprintf(
    49                                         /* translators: %s: AI provider ID. */
    50                                         __( 'The provider "%s" is not registered in the AI client registry.' ),
    51                                         $provider_id
    52                                 ),
    53                                 '7.0.0'
    54                         );
    55                         return null;
    56                 }
    57 
    58                 $registry->setProviderRequestAuthentication(
    59                         $provider_id,
    60                         new ApiKeyRequestAuthentication( $key )
    61                 );
    62 
    63                 return $registry->isProviderConfigured( $provider_id );
    64         } catch ( Exception $e ) {
    65                 wp_trigger_error( __FUNCTION__, $e->getMessage() );
     13/**
     14 * Checks if a connector is registered.
     15 *
     16 * @since 7.0.0
     17 *
     18 * @see WP_Connector_Registry::is_registered()
     19 *
     20 * @param string $id The connector identifier.
     21 * @return bool True if the connector is registered, false otherwise.
     22 */
     23function wp_is_connector_registered( string $id ): bool {
     24        $registry = WP_Connector_Registry::get_instance();
     25        if ( null === $registry ) {
     26                return false;
     27        }
     28
     29        return $registry->is_registered( $id );
     30}
     31
     32/**
     33 * Retrieves a registered connector.
     34 *
     35 * @since 7.0.0
     36 *
     37 * @see WP_Connector_Registry::get_registered()
     38 *
     39 * @param string $id The connector identifier.
     40 * @return array|null The registered connector data, or null if not registered.
     41 */
     42function wp_get_connector( string $id ): ?array {
     43        $registry = WP_Connector_Registry::get_instance();
     44        if ( null === $registry ) {
    6645                return null;
    6746        }
    68 }
    69 
    70 /**
    71  * Retrieves the real (unmasked) value of a connector API key.
    72  *
    73  * Temporarily removes the masking filter, reads the option, then re-adds it.
    74  *
    75  * @since 7.0.0
    76  * @access private
    77  *
    78  * @param string   $option_name   The option name for the API key.
    79  * @param callable $mask_callback The mask filter function.
    80  * @return string The real API key value.
    81  */
    82 function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
    83         remove_filter( "option_{$option_name}", $mask_callback );
    84         $value = get_option( $option_name, '' );
    85         add_filter( "option_{$option_name}", $mask_callback );
    86         return (string) $value;
    87 }
    88 
    89 /**
    90  * Gets the registered connector settings.
    91  *
    92  * @since 7.0.0
    93  * @access private
    94  *
    95  * @return array {
    96  *     Connector settings keyed by connector ID.
    97  *
    98  *     @type array ...$0 {
    99  *         Data for a single connector.
    100  *
    101  *         @type string $name           The connector's display name.
    102  *         @type string $description    The connector's description.
    103  *         @type string $type           The connector type. Currently, only 'ai_provider' is supported.
    104  *         @type array  $plugin         Optional. Plugin data for install/activate UI.
    105  *             @type string $slug       The WordPress.org plugin slug.
    106  *         }
    107  *         @type array  $authentication {
    108  *             Authentication configuration. When method is 'api_key', includes
    109  *             credentials_url and setting_name. When 'none', only method is present.
    110  *
    111  *             @type string      $method          The authentication method: 'api_key' or 'none'.
    112  *             @type string|null $credentials_url Optional. URL where users can obtain API credentials.
    113  *             @type string      $setting_name    Optional. The setting name for the API key.
    114  *         }
    115  *     }
    116  * }
    117  */
    118 function _wp_connectors_get_connector_settings(): array {
    119         $connectors = array(
     47
     48        return $registry->get_registered( $id );
     49}
     50
     51/**
     52 * Retrieves all registered connectors.
     53 *
     54 * @since 7.0.0
     55 *
     56 * @see WP_Connector_Registry::get_all_registered()
     57 *
     58 * @return array[] An array of registered connectors keyed by connector ID.
     59 */
     60function wp_get_connectors(): array {
     61        $registry = WP_Connector_Registry::get_instance();
     62        if ( null === $registry ) {
     63                return array();
     64        }
     65
     66        return $registry->get_all_registered();
     67}
     68
     69/**
     70 * Resolves an AI provider logo file path to a URL.
     71 *
     72 * Converts an absolute file path to a plugin URL. The path must reside within
     73 * the plugins or must-use plugins directory.
     74 *
     75 * @since 7.0.0
     76 * @access private
     77 *
     78 * @param string $path Absolute path to the logo file.
     79 * @return string|null The URL to the logo file, or null if the path is invalid.
     80 */
     81function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string {
     82        if ( ! $path ) {
     83                return null;
     84        }
     85
     86        $path = wp_normalize_path( $path );
     87
     88        if ( ! file_exists( $path ) ) {
     89                return null;
     90        }
     91
     92        $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR );
     93        if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) {
     94                return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' );
     95        }
     96
     97        $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
     98        if ( str_starts_with( $path, $plugin_dir . '/' ) ) {
     99                return plugins_url( substr( $path, strlen( $plugin_dir ) ) );
     100        }
     101
     102        _doing_it_wrong(
     103                __FUNCTION__,
     104                __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ),
     105                '7.0.0'
     106        );
     107
     108        return null;
     109}
     110
     111/**
     112 * Initializes the connector registry with default connectors and fires the registration action.
     113 *
     114 * Creates the registry instance, registers built-in connectors (which cannot be unhooked),
     115 * and then fires the `wp_connectors_init` action for plugins to register their own connectors.
     116 *
     117 * @since 7.0.0
     118 * @access private
     119 */
     120function _wp_connectors_init(): void {
     121        $registry = new WP_Connector_Registry();
     122        WP_Connector_Registry::set_instance( $registry );
     123        // Built-in connectors.
     124        $defaults = array(
    120125                'anthropic' => array(
    121126                        'name'           => 'Anthropic',
     
    156161        );
    157162
    158         $registry = AiClient::defaultRegistry();
    159 
    160         foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
    161                 $provider_class_name = $registry->getProviderClassName( $connector_id );
     163        // Merge AI Client registry data on top of defaults.
     164        // Registry values (from provider plugins) take precedence over hardcoded fallbacks.
     165        $ai_registry = AiClient::defaultRegistry();
     166
     167        foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) {
     168                $provider_class_name = $ai_registry->getProviderClassName( $connector_id );
    162169                $provider_metadata   = $provider_class_name::metadata();
    163170
     
    177184                $name        = $provider_metadata->getName();
    178185                $description = $provider_metadata->getDescription();
    179 
    180                 if ( isset( $connectors[ $connector_id ] ) ) {
     186                $logo_url    = $provider_metadata->getLogoPath()
     187                        ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() )
     188                        : null;
     189
     190                if ( isset( $defaults[ $connector_id ] ) ) {
    181191                        // Override fields with non-empty registry values.
    182192                        if ( $name ) {
    183                                 $connectors[ $connector_id ]['name'] = $name;
     193                                $defaults[ $connector_id ]['name'] = $name;
    184194                        }
    185195                        if ( $description ) {
    186                                 $connectors[ $connector_id ]['description'] = $description;
     196                                $defaults[ $connector_id ]['description'] = $description;
     197                        }
     198                        if ( $logo_url ) {
     199                                $defaults[ $connector_id ]['logo_url'] = $logo_url;
    187200                        }
    188201                        // Always update auth method; keep existing credentials_url as fallback.
    189                         $connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
     202                        $defaults[ $connector_id ]['authentication']['method'] = $authentication['method'];
    190203                        if ( ! empty( $authentication['credentials_url'] ) ) {
    191                                 $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
     204                                $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
    192205                        }
    193206                } else {
    194                         $connectors[ $connector_id ] = array(
     207                        $defaults[ $connector_id ] = array(
    195208                                'name'           => $name ? $name : ucwords( $connector_id ),
    196209                                'description'    => $description ? $description : '',
    197210                                'type'           => 'ai_provider',
    198211                                'authentication' => $authentication,
     212                                'logo_url'       => $logo_url,
    199213                        );
    200214                }
    201215        }
    202216
     217        // Register all default connectors directly on the registry.
     218        foreach ( $defaults as $id => $args ) {
     219                $registry->register( $id, $args );
     220        }
     221
     222        /**
     223         * Fires when the connector registry is ready for plugins to register connectors.
     224         *
     225         * Default connectors have already been registered at this point and cannot be
     226         * unhooked. Use `$registry->register()` within this action to add new connectors.
     227         *
     228         * Example usage:
     229         *
     230         *     add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
     231         *         $registry->register(
     232         *             'my_custom_ai',
     233         *             array(
     234         *                 'name'           => __( 'My Custom AI', 'my-plugin' ),
     235         *                 'description'    => __( 'Custom AI provider integration.', 'my-plugin' ),
     236         *                 'type'           => 'ai_provider',
     237         *                 'authentication' => array(
     238         *                     'method'          => 'api_key',
     239         *                     'credentials_url' => 'https://example.com/api-keys',
     240         *                 ),
     241         *             )
     242         *         );
     243         *     } );
     244         *
     245         * @since 7.0.0
     246         *
     247         * @param WP_Connector_Registry $registry Connector registry instance.
     248         */
     249        do_action( 'wp_connectors_init', $registry );
     250}
     251
     252/**
     253 * Masks an API key, showing only the last 4 characters.
     254 *
     255 * @since 7.0.0
     256 * @access private
     257 *
     258 * @param string $key The API key to mask.
     259 * @return string The masked key, e.g. "************fj39".
     260 */
     261function _wp_connectors_mask_api_key( string $key ): string {
     262        if ( strlen( $key ) <= 4 ) {
     263                return $key;
     264        }
     265
     266        return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
     267}
     268
     269/**
     270 * Checks whether an API key is valid for a given provider.
     271 *
     272 * @since 7.0.0
     273 * @access private
     274 *
     275 * @param string $key         The API key to check.
     276 * @param string $provider_id The WP AI client provider ID.
     277 * @return bool|null True if valid, false if invalid, null if unable to determine.
     278 */
     279function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
     280        try {
     281                $registry = AiClient::defaultRegistry();
     282
     283                if ( ! $registry->hasProvider( $provider_id ) ) {
     284                        _doing_it_wrong(
     285                                __FUNCTION__,
     286                                sprintf(
     287                                        /* translators: %s: AI provider ID. */
     288                                        __( 'The provider "%s" is not registered in the AI client registry.' ),
     289                                        $provider_id
     290                                ),
     291                                '7.0.0'
     292                        );
     293                        return null;
     294                }
     295
     296                $registry->setProviderRequestAuthentication(
     297                        $provider_id,
     298                        new ApiKeyRequestAuthentication( $key )
     299                );
     300
     301                return $registry->isProviderConfigured( $provider_id );
     302        } catch ( Exception $e ) {
     303                wp_trigger_error( __FUNCTION__, $e->getMessage() );
     304                return null;
     305        }
     306}
     307
     308/**
     309 * Retrieves the real (unmasked) value of a connector API key.
     310 *
     311 * Temporarily removes the masking filter, reads the option, then re-adds it.
     312 *
     313 * @since 7.0.0
     314 * @access private
     315 *
     316 * @param string   $option_name   The option name for the API key.
     317 * @param callable $mask_callback The mask filter function.
     318 * @return string The real API key value.
     319 */
     320function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
     321        remove_filter( "option_{$option_name}", $mask_callback );
     322        $value = get_option( $option_name, '' );
     323        add_filter( "option_{$option_name}", $mask_callback );
     324        return (string) $value;
     325}
     326
     327/**
     328 * Gets the registered connector settings.
     329 *
     330 * @since 7.0.0
     331 * @access private
     332 *
     333 * @return array {
     334 *     Connector settings keyed by connector ID.
     335 *
     336 *     @type array ...$0 {
     337 *         Data for a single connector.
     338 *
     339 *         @type string $name           The connector's display name.
     340 *         @type string $description    The connector's description.
     341 *         @type string $type           The connector type. Currently, only 'ai_provider' is supported.
     342 *         @type array  $plugin         Optional. Plugin data for install/activate UI.
     343 *             @type string $slug       The WordPress.org plugin slug.
     344 *         }
     345 *         @type array  $authentication {
     346 *             Authentication configuration. When method is 'api_key', includes
     347 *             credentials_url and setting_name. When 'none', only method is present.
     348 *
     349 *             @type string      $method          The authentication method: 'api_key' or 'none'.
     350 *             @type string|null $credentials_url Optional. URL where users can obtain API credentials.
     351 *             @type string      $setting_name    Optional. The setting name for the API key.
     352 *         }
     353 *     }
     354 * }
     355 */
     356function _wp_connectors_get_connector_settings(): array {
     357        $connectors = wp_get_connectors();
    203358        ksort( $connectors );
    204 
    205         // Add setting_name for connectors that use API key authentication.
    206         foreach ( $connectors as $connector_id => $connector ) {
    207                 if ( 'api_key' === $connector['authentication']['method'] ) {
    208                         $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";
    209                 }
    210         }
    211 
    212359        return $connectors;
    213360}
     
    283430 */
    284431function _wp_register_default_connector_settings(): void {
     432        $ai_registry = AiClient::defaultRegistry();
     433
    285434        foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
    286435                $auth = $connector_data['authentication'];
    287436                if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
     437                        continue;
     438                }
     439
     440                // Skip registering the setting if the provider is not in the registry.
     441                if ( ! $ai_registry->hasProvider( $connector_id ) ) {
    288442                        continue;
    289443                }
     
    331485function _wp_connectors_pass_default_keys_to_ai_client(): void {
    332486        try {
    333                 $registry = AiClient::defaultRegistry();
     487                $ai_registry = AiClient::defaultRegistry();
    334488                foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
    335489                        if ( 'ai_provider' !== $connector_data['type'] ) {
     
    342496                        }
    343497
     498                        if ( ! $ai_registry->hasProvider( $connector_id ) ) {
     499                                continue;
     500                        }
     501
    344502                        $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
    345                         if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
     503                        if ( '' === $api_key ) {
    346504                                continue;
    347505                        }
    348506
    349                         $registry->setProviderRequestAuthentication(
     507                        $ai_registry->setProviderRequestAuthentication(
    350508                                $connector_id,
    351509                                new ApiKeyRequestAuthentication( $api_key )
     
    353511                }
    354512        } catch ( Exception $e ) {
    355                         wp_trigger_error( __FUNCTION__, $e->getMessage() );
     513                wp_trigger_error( __FUNCTION__, $e->getMessage() );
    356514        }
    357515}
  • trunk/src/wp-includes/default-filters.php

    r61866 r61943  
    539539add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' );
    540540add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );
     541
     542// Connectors API.
     543add_action( 'init', '_wp_connectors_init' );
    541544
    542545// Sitemaps actions.
  • trunk/src/wp-settings.php

    r61749 r61943  
    295295require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
    296296require ABSPATH . WPINC . '/ai-client.php';
     297require ABSPATH . WPINC . '/class-wp-connector-registry.php';
    297298require ABSPATH . WPINC . '/connectors.php';
    298299require ABSPATH . WPINC . '/class-wp-icons-registry.php';
  • trunk/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php

    r61824 r61943  
    156156         */
    157157        private static function register_mock_connectors_provider(): void {
    158                 $registry = AiClient::defaultRegistry();
    159                 if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) {
    160                         $registry->registerProvider( Mock_Connectors_Test_Provider::class );
     158                $ai_registry = AiClient::defaultRegistry();
     159                if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) {
     160                        $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class );
     161                }
     162
     163                // Also register in the WP connector registry if not already present.
     164                $connector_registry = WP_Connector_Registry::get_instance();
     165                if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) {
     166                        $connector_registry->register(
     167                                'mock_connectors_test',
     168                                array(
     169                                        'name'           => 'Mock Connectors Test',
     170                                        'description'    => '',
     171                                        'type'           => 'ai_provider',
     172                                        'authentication' => array(
     173                                                'method'          => 'api_key',
     174                                                'credentials_url' => null,
     175                                        ),
     176                                )
     177                        );
    161178                }
    162179        }
  • trunk/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php

    r61824 r61943  
    116116                $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] );
    117117        }
     118
     119        /**
     120         * @ticket 64730
     121         */
     122        public function test_connectors_are_sorted_alphabetically() {
     123                $connectors = _wp_connectors_get_connector_settings();
     124                $keys       = array_keys( $connectors );
     125                $sorted     = $keys;
     126                sort( $sorted );
     127
     128                $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' );
     129        }
    118130}
  • trunk/tests/phpunit/tests/rest-api/rest-settings-controller.php

    r61833 r61943  
    121121                        'site_icon', // Registered in wp-includes/blocks/site-logo.php
    122122                        'wp_enable_real_time_collaboration',
    123                         // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php.
    124                         'connectors_ai_anthropic_api_key',
    125                         'connectors_ai_google_api_key',
    126                         'connectors_ai_openai_api_key',
    127123                );
    128124
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r61833 r61943  
    1106711067                    ],
    1106811068                    "args": {
    11069                         "connectors_ai_anthropic_api_key": {
    11070                             "title": "Anthropic API Key",
    11071                             "description": "API key for the Anthropic AI provider.",
    11072                             "type": "string",
    11073                             "required": false
    11074                         },
    11075                         "connectors_ai_google_api_key": {
    11076                             "title": "Google API Key",
    11077                             "description": "API key for the Google AI provider.",
    11078                             "type": "string",
    11079                             "required": false
    11080                         },
    11081                         "connectors_ai_openai_api_key": {
    11082                             "title": "OpenAI API Key",
    11083                             "description": "API key for the OpenAI AI provider.",
    11084                             "type": "string",
    11085                             "required": false
    11086                         },
    1108711069                        "title": {
    1108811070                            "title": "Title",
     
    1476314745
    1476414746mockedApiResponse.settings = {
    14765     "connectors_ai_anthropic_api_key": "",
    14766     "connectors_ai_google_api_key": "",
    14767     "connectors_ai_openai_api_key": "",
    1476814747    "title": "Test Blog",
    1476914748    "description": "",
Note: See TracChangeset for help on using the changeset viewer.