Make WordPress Core

Changeset 61943


Ignore:
Timestamp:
03/11/2026 04:10:06 PM (2 weeks 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.