diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php
new file mode 100644
index 0000000000000..5f5e0ed93ea62
--- /dev/null
+++ b/src/wp-includes/class-wp-connector-registry.php
@@ -0,0 +1,298 @@
+is_registered( $id ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ // Validate required fields.
+ if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ $connector = array(
+ 'name' => $args['name'],
+ 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '',
+ 'type' => $args['type'],
+ 'authentication' => array(
+ 'method' => $args['authentication']['method'],
+ ),
+ );
+
+ if ( 'api_key' === $args['authentication']['method'] ) {
+ $connector['authentication']['credentials_url'] = isset( $args['authentication']['credentials_url'] ) ? $args['authentication']['credentials_url'] : null;
+ $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key";
+ }
+
+ if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) {
+ $connector['plugin'] = $args['plugin'];
+ }
+
+ $this->registered_connectors[ $id ] = $connector;
+ return $connector;
+ }
+
+ /**
+ * Unregisters a connector.
+ *
+ * Do not use this method directly. Instead, use the `wp_unregister_connector()` function.
+ *
+ * @since 7.0.0
+ *
+ * @see wp_unregister_connector()
+ *
+ * @param string $id The connector identifier.
+ * @return array|null The unregistered connector data on success, null on failure.
+ */
+ public function unregister( string $id ): ?array {
+ if ( ! $this->is_registered( $id ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ $unregistered = $this->registered_connectors[ $id ];
+ unset( $this->registered_connectors[ $id ] );
+
+ return $unregistered;
+ }
+
+ /**
+ * Retrieves the list of all registered connectors.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_connectors()` function.
+ *
+ * @since 7.0.0
+ *
+ * @see wp_get_connectors()
+ *
+ * @return array[] The array of registered connectors keyed by connector ID.
+ */
+ public function get_all_registered(): array {
+ return $this->registered_connectors;
+ }
+
+ /**
+ * Checks if a connector is registered.
+ *
+ * Do not use this method directly. Instead, use the `wp_has_connector()` function.
+ *
+ * @since 7.0.0
+ *
+ * @see wp_has_connector()
+ *
+ * @param string $id The connector identifier.
+ * @return bool True if the connector is registered, false otherwise.
+ */
+ public function is_registered( string $id ): bool {
+ return isset( $this->registered_connectors[ $id ] );
+ }
+
+ /**
+ * Retrieves a registered connector.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_connector()` function.
+ *
+ * @since 7.0.0
+ *
+ * @see wp_get_connector()
+ *
+ * @param string $id The connector identifier.
+ * @return array|null The registered connector data, or null if it is not registered.
+ */
+ public function get_registered( string $id ): ?array {
+ if ( ! $this->is_registered( $id ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Connector ID. */
+ sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ),
+ '7.0.0'
+ );
+ return null;
+ }
+ return $this->registered_connectors[ $id ];
+ }
+
+ /**
+ * Utility method to retrieve the main instance of the registry class.
+ *
+ * The instance will be created if it does not exist yet.
+ *
+ * @since 7.0.0
+ *
+ * @return WP_Connector_Registry|null The main registry instance, or null when `init` action has not fired.
+ */
+ public static function get_instance(): ?self {
+ if ( ! did_action( 'init' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: init action. */
+ __( 'Connector registry should not be initialized before the %s action has fired.' ),
+ 'init'
+ ),
+ '7.0.0'
+ );
+ return null;
+ }
+
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+
+ /**
+ * Fires when preparing connector registry.
+ *
+ * Connectors should be registered on this action rather
+ * than another action to ensure they're only loaded when needed.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_Connector_Registry $instance Connector registry object.
+ */
+ do_action( 'wp_connectors_init', self::$instance );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 7.0.0
+ * @throws LogicException If the registry object is unserialized.
+ */
+ public function __wakeup(): void {
+ throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+ }
+
+ /**
+ * Sleep magic method.
+ *
+ * @since 7.0.0
+ * @throws LogicException If the registry object is serialized.
+ */
+ public function __sleep(): array {
+ throw new LogicException( __CLASS__ . ' should never be serialized.' );
+ }
+}
diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php
index 0da60353705c2..fb05b13d044be 100644
--- a/src/wp-includes/connectors.php
+++ b/src/wp-includes/connectors.php
@@ -10,113 +10,163 @@
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
-
/**
- * Masks an API key, showing only the last 4 characters.
+ * Registers a new connector.
+ *
+ * Must be called during the `wp_connectors_init` action.
+ *
+ * Example:
+ *
+ * function my_plugin_register_connectors( WP_Connector_Registry $registry ): void {
+ * wp_register_connector(
+ * 'my_custom_ai',
+ * array(
+ * 'name' => __( 'My Custom AI', 'my-plugin' ),
+ * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ),
+ * 'type' => 'ai_provider',
+ * 'authentication' => array(
+ * 'method' => 'api_key',
+ * 'credentials_url' => 'https://example.com/api-keys',
+ * ),
+ * )
+ * );
+ * }
+ * add_action( 'wp_connectors_init', 'my_plugin_register_connectors' );
*
* @since 7.0.0
- * @access private
*
- * @param string $key The API key to mask.
- * @return string The masked key, e.g. "************fj39".
+ * @see WP_Connector_Registry::register()
+ *
+ * @param string $id The unique connector identifier. Must contain only lowercase
+ * alphanumeric characters and underscores.
+ * @param array $args {
+ * An associative array of arguments for the connector.
+ *
+ * @type string $name Required. The connector's display name.
+ * @type string $description Optional. The connector's description. Default empty string.
+ * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported.
+ * @type array $authentication {
+ * Required. Authentication configuration.
+ *
+ * @type string $method Required. The authentication method: 'api_key' or 'none'.
+ * @type string|null $credentials_url Optional. URL where users can obtain API credentials.
+ * }
+ * @type array $plugin Optional. Plugin data for install/activate UI.
+ * @type string $slug The WordPress.org plugin slug.
+ * }
+ * }
+ * @return array|null The registered connector data on success, null on failure.
*/
-function _wp_connectors_mask_api_key( string $key ): string {
- if ( strlen( $key ) <= 4 ) {
- return $key;
+function wp_register_connector( string $id, array $args ): ?array {
+ if ( ! doing_action( 'wp_connectors_init' ) ) {
+ _doing_it_wrong(
+ __FUNCTION__,
+ sprintf(
+ /* translators: 1: wp_connectors_init, 2: string value of the connector ID. */
+ __( 'Connectors must be registered on the %1$s action. The connector %2$s was not registered.' ),
+ 'wp_connectors_init',
+ '' . esc_html( $id ) . ''
+ ),
+ '7.0.0'
+ );
+ return null;
}
- return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
+ $registry = WP_Connector_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->register( $id, $args );
}
/**
- * Checks whether an API key is valid for a given provider.
+ * Unregisters a connector.
+ *
+ * Can be called at any time after the connector has been registered.
*
* @since 7.0.0
- * @access private
*
- * @param string $key The API key to check.
- * @param string $provider_id The WP AI client provider ID.
- * @return bool|null True if valid, false if invalid, null if unable to determine.
+ * @see WP_Connector_Registry::unregister()
+ * @see wp_register_connector()
+ *
+ * @param string $id The connector identifier.
+ * @return array|null The unregistered connector data on success, null on failure.
*/
-function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
- try {
- $registry = AiClient::defaultRegistry();
-
- if ( ! $registry->hasProvider( $provider_id ) ) {
- _doing_it_wrong(
- __FUNCTION__,
- sprintf(
- /* translators: %s: AI provider ID. */
- __( 'The provider "%s" is not registered in the AI client registry.' ),
- $provider_id
- ),
- '7.0.0'
- );
- return null;
- }
-
- $registry->setProviderRequestAuthentication(
- $provider_id,
- new ApiKeyRequestAuthentication( $key )
- );
-
- return $registry->isProviderConfigured( $provider_id );
- } catch ( Exception $e ) {
- wp_trigger_error( __FUNCTION__, $e->getMessage() );
+function wp_unregister_connector( string $id ): ?array {
+ $registry = WP_Connector_Registry::get_instance();
+ if ( null === $registry ) {
return null;
}
+
+ return $registry->unregister( $id );
}
/**
- * Retrieves the real (unmasked) value of a connector API key.
- *
- * Temporarily removes the masking filter, reads the option, then re-adds it.
+ * Checks if a connector is registered.
*
* @since 7.0.0
- * @access private
*
- * @param string $option_name The option name for the API key.
- * @param callable $mask_callback The mask filter function.
- * @return string The real API key value.
+ * @see WP_Connector_Registry::is_registered()
+ *
+ * @param string $id The connector identifier.
+ * @return bool True if the connector is registered, false otherwise.
*/
-function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
- remove_filter( "option_{$option_name}", $mask_callback );
- $value = get_option( $option_name, '' );
- add_filter( "option_{$option_name}", $mask_callback );
- return (string) $value;
+function wp_has_connector( string $id ): bool {
+ $registry = WP_Connector_Registry::get_instance();
+ if ( null === $registry ) {
+ return false;
+ }
+
+ return $registry->is_registered( $id );
}
/**
- * Gets the registered connector settings.
+ * Retrieves a registered connector.
*
* @since 7.0.0
- * @access private
*
- * @return array {
- * Connector settings keyed by connector ID.
+ * @see WP_Connector_Registry::get_registered()
*
- * @type array ...$0 {
- * Data for a single connector.
+ * @param string $id The connector identifier.
+ * @return array|null The registered connector data, or null if not registered.
+ */
+function wp_get_connector( string $id ): ?array {
+ $registry = WP_Connector_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->get_registered( $id );
+}
+
+/**
+ * Retrieves all registered connectors.
*
- * @type string $name The connector's display name.
- * @type string $description The connector's description.
- * @type string $type The connector type. Currently, only 'ai_provider' is supported.
- * @type array $plugin Optional. Plugin data for install/activate UI.
- * @type string $slug The WordPress.org plugin slug.
- * }
- * @type array $authentication {
- * Authentication configuration. When method is 'api_key', includes
- * credentials_url and setting_name. When 'none', only method is present.
+ * @since 7.0.0
*
- * @type string $method The authentication method: 'api_key' or 'none'.
- * @type string|null $credentials_url Optional. URL where users can obtain API credentials.
- * @type string $setting_name Optional. The setting name for the API key.
- * }
- * }
- * }
+ * @see WP_Connector_Registry::get_all_registered()
+ *
+ * @return array[] An array of registered connectors keyed by connector ID.
*/
-function _wp_connectors_get_connector_settings(): array {
- $connectors = array(
+function wp_get_connectors(): array {
+ $registry = WP_Connector_Registry::get_instance();
+ if ( null === $registry ) {
+ return array();
+ }
+
+ return $registry->get_all_registered();
+}
+
+/**
+ * Registers default connectors from Core and the AI Client registry.
+ *
+ * @since 7.0.0
+ * @access private
+ */
+function _wp_register_default_connectors(): void {
+ // Built-in connectors.
+ $defaults = array(
'anthropic' => array(
'name' => 'Anthropic',
'description' => __( 'Text generation with Claude.' ),
@@ -155,10 +205,12 @@ function _wp_connectors_get_connector_settings(): array {
),
);
- $registry = AiClient::defaultRegistry();
+ // Merge AI Client registry data on top of defaults.
+ // Registry values (from provider plugins) take precedence over hardcoded fallbacks.
+ $ai_registry = AiClient::defaultRegistry();
- foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
- $provider_class_name = $registry->getProviderClassName( $connector_id );
+ foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) {
+ $provider_class_name = $ai_registry->getProviderClassName( $connector_id );
$provider_metadata = $provider_class_name::metadata();
$auth_method = $provider_metadata->getAuthenticationMethod();
@@ -177,21 +229,21 @@ function _wp_connectors_get_connector_settings(): array {
$name = $provider_metadata->getName();
$description = $provider_metadata->getDescription();
- if ( isset( $connectors[ $connector_id ] ) ) {
+ if ( isset( $defaults[ $connector_id ] ) ) {
// Override fields with non-empty registry values.
if ( $name ) {
- $connectors[ $connector_id ]['name'] = $name;
+ $defaults[ $connector_id ]['name'] = $name;
}
if ( $description ) {
- $connectors[ $connector_id ]['description'] = $description;
+ $defaults[ $connector_id ]['description'] = $description;
}
// Always update auth method; keep existing credentials_url as fallback.
- $connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
+ $defaults[ $connector_id ]['authentication']['method'] = $authentication['method'];
if ( ! empty( $authentication['credentials_url'] ) ) {
- $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
+ $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
}
} else {
- $connectors[ $connector_id ] = array(
+ $defaults[ $connector_id ] = array(
'name' => $name ? $name : ucwords( $connector_id ),
'description' => $description ? $description : '',
'type' => 'ai_provider',
@@ -200,15 +252,119 @@ function _wp_connectors_get_connector_settings(): array {
}
}
- ksort( $connectors );
+ // Register all connectors.
+ foreach ( $defaults as $id => $args ) {
+ wp_register_connector( $id, $args );
+ }
+}
- // Add setting_name for connectors that use API key authentication.
- foreach ( $connectors as $connector_id => $connector ) {
- if ( 'api_key' === $connector['authentication']['method'] ) {
- $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";
+/**
+ * Masks an API key, showing only the last 4 characters.
+ *
+ * @since 7.0.0
+ * @access private
+ *
+ * @param string $key The API key to mask.
+ * @return string The masked key, e.g. "************fj39".
+ */
+function _wp_connectors_mask_api_key( string $key ): string {
+ if ( strlen( $key ) <= 4 ) {
+ return $key;
+ }
+
+ return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
+}
+
+/**
+ * Checks whether an API key is valid for a given provider.
+ *
+ * @since 7.0.0
+ * @access private
+ *
+ * @param string $key The API key to check.
+ * @param string $provider_id The WP AI client provider ID.
+ * @return bool|null True if valid, false if invalid, null if unable to determine.
+ */
+function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
+ try {
+ $registry = AiClient::defaultRegistry();
+
+ if ( ! $registry->hasProvider( $provider_id ) ) {
+ _doing_it_wrong(
+ __FUNCTION__,
+ sprintf(
+ /* translators: %s: AI provider ID. */
+ __( 'The provider "%s" is not registered in the AI client registry.' ),
+ $provider_id
+ ),
+ '7.0.0'
+ );
+ return null;
}
+
+ $registry->setProviderRequestAuthentication(
+ $provider_id,
+ new ApiKeyRequestAuthentication( $key )
+ );
+
+ return $registry->isProviderConfigured( $provider_id );
+ } catch ( Exception $e ) {
+ wp_trigger_error( __FUNCTION__, $e->getMessage() );
+ return null;
}
+}
+/**
+ * Retrieves the real (unmasked) value of a connector API key.
+ *
+ * Temporarily removes the masking filter, reads the option, then re-adds it.
+ *
+ * @since 7.0.0
+ * @access private
+ *
+ * @param string $option_name The option name for the API key.
+ * @param callable $mask_callback The mask filter function.
+ * @return string The real API key value.
+ */
+function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
+ remove_filter( "option_{$option_name}", $mask_callback );
+ $value = get_option( $option_name, '' );
+ add_filter( "option_{$option_name}", $mask_callback );
+ return (string) $value;
+}
+
+/**
+ * Gets the registered connector settings.
+ *
+ * @since 7.0.0
+ * @access private
+ *
+ * @return array {
+ * Connector settings keyed by connector ID.
+ *
+ * @type array ...$0 {
+ * Data for a single connector.
+ *
+ * @type string $name The connector's display name.
+ * @type string $description The connector's description.
+ * @type string $type The connector type. Currently, only 'ai_provider' is supported.
+ * @type array $plugin Optional. Plugin data for install/activate UI.
+ * @type string $slug The WordPress.org plugin slug.
+ * }
+ * @type array $authentication {
+ * Authentication configuration. When method is 'api_key', includes
+ * credentials_url and setting_name. When 'none', only method is present.
+ *
+ * @type string $method The authentication method: 'api_key' or 'none'.
+ * @type string|null $credentials_url Optional. URL where users can obtain API credentials.
+ * @type string $setting_name Optional. The setting name for the API key.
+ * }
+ * }
+ * }
+ */
+function _wp_connectors_get_connector_settings(): array {
+ $connectors = wp_get_connectors();
+ ksort( $connectors );
return $connectors;
}
@@ -282,12 +438,19 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
* @access private
*/
function _wp_register_default_connector_settings(): void {
+ $ai_registry = AiClient::defaultRegistry();
+
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}
+ // Skip registering the setting if the provider is not in the registry.
+ if ( ! $ai_registry->hasProvider( $connector_id ) ) {
+ continue;
+ }
+
$setting_name = $auth['setting_name'];
register_setting(
'connectors',
@@ -330,7 +493,7 @@ function _wp_register_default_connector_settings(): void {
*/
function _wp_connectors_pass_default_keys_to_ai_client(): void {
try {
- $registry = AiClient::defaultRegistry();
+ $ai_registry = AiClient::defaultRegistry();
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
if ( 'ai_provider' !== $connector_data['type'] ) {
continue;
@@ -341,12 +504,16 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
continue;
}
+ if ( ! $ai_registry->hasProvider( $connector_id ) ) {
+ continue;
+ }
+
$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
- if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
+ if ( '' === $api_key ) {
continue;
}
- $registry->setProviderRequestAuthentication(
+ $ai_registry->setProviderRequestAuthentication(
$connector_id,
new ApiKeyRequestAuthentication( $api_key )
);
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 0bcd2d6b15acb..a6a6fb7d3964e 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -539,6 +539,9 @@
add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' );
add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );
+// Connectors API.
+add_action( 'wp_connectors_init', '_wp_register_default_connectors' );
+
// Sitemaps actions.
add_action( 'init', 'wp_sitemaps_get_server' );
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 023cdccd5ecc9..dab1d8fd4c0de 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -294,6 +294,7 @@
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php';
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
require ABSPATH . WPINC . '/ai-client.php';
+require ABSPATH . WPINC . '/class-wp-connector-registry.php';
require ABSPATH . WPINC . '/connectors.php';
require ABSPATH . WPINC . '/class-wp-icons-registry.php';
require ABSPATH . WPINC . '/widgets.php';
diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
index 9797017451e0d..e7637bf239119 100644
--- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
+++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
@@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait {
* Must be called from set_up_before_class() after parent::set_up_before_class().
*/
private static function register_mock_connectors_provider(): void {
- $registry = AiClient::defaultRegistry();
- if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) {
- $registry->registerProvider( Mock_Connectors_Test_Provider::class );
+ $ai_registry = AiClient::defaultRegistry();
+ if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) {
+ $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class );
+ }
+
+ // Also register in the WP connector registry if not already present.
+ $connector_registry = WP_Connector_Registry::get_instance();
+ if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) {
+ $connector_registry->register(
+ 'mock_connectors_test',
+ array(
+ 'name' => 'Mock Connectors Test',
+ 'description' => '',
+ 'type' => 'ai_provider',
+ 'authentication' => array(
+ 'method' => 'api_key',
+ 'credentials_url' => null,
+ ),
+ )
+ );
}
}
diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php
new file mode 100644
index 0000000000000..2d82d3fdf9604
--- /dev/null
+++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php
@@ -0,0 +1,340 @@
+registry = new WP_Connector_Registry();
+
+ self::$default_args = array(
+ 'name' => 'Test Provider',
+ 'description' => 'A test AI provider.',
+ 'type' => 'ai_provider',
+ 'authentication' => array(
+ 'method' => 'api_key',
+ 'credentials_url' => 'https://example.com/keys',
+ ),
+ );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_returns_connector_data() {
+ $result = $this->registry->register( 'test_provider', self::$default_args );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ $this->assertSame( 'A test AI provider.', $result['description'] );
+ $this->assertSame( 'ai_provider', $result['type'] );
+ $this->assertSame( 'api_key', $result['authentication']['method'] );
+ $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] );
+ $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_generates_setting_name_for_api_key() {
+ $result = $this->registry->register( 'my_ai', self::$default_args );
+
+ $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_no_setting_name_for_none_auth() {
+ $args = array(
+ 'name' => 'No Auth Provider',
+ 'type' => 'ai_provider',
+ 'authentication' => array( 'method' => 'none' ),
+ );
+ $result = $this->registry->register( 'no_auth', $args );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_defaults_description_to_empty_string() {
+ $args = array(
+ 'name' => 'Minimal',
+ 'type' => 'ai_provider',
+ 'authentication' => array( 'method' => 'none' ),
+ );
+
+ $result = $this->registry->register( 'minimal', $args );
+
+ $this->assertSame( '', $result['description'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_includes_plugin_data() {
+ $args = self::$default_args;
+ $args['plugin'] = array( 'slug' => 'my-plugin' );
+
+ $result = $this->registry->register( 'with_plugin', $args );
+
+ $this->assertArrayHasKey( 'plugin', $result );
+ $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_omits_plugin_when_not_provided() {
+ $result = $this->registry->register( 'no_plugin', self::$default_args );
+
+ $this->assertArrayNotHasKey( 'plugin', $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_id_with_uppercase() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( 'InvalidId', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_id_with_dashes() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( 'my-provider', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_empty_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $result = $this->registry->register( '', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_duplicate_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $this->registry->register( 'duplicate', self::$default_args );
+ $result = $this->registry->register( 'duplicate', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_name() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['name'] );
+
+ $result = $this->registry->register( 'no_name', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_empty_name() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ $args['name'] = '';
+
+ $result = $this->registry->register( 'empty_name', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_type() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['type'] );
+
+ $result = $this->registry->register( 'no_type', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_missing_authentication() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ unset( $args['authentication'] );
+
+ $result = $this->registry->register( 'no_auth', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_rejects_invalid_auth_method() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' );
+
+ $args = self::$default_args;
+ $args['authentication']['method'] = 'oauth';
+
+ $result = $this->registry->register( 'bad_auth', $args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_is_registered_returns_true_for_registered() {
+ $this->registry->register( 'exists', self::$default_args );
+
+ $this->assertTrue( $this->registry->is_registered( 'exists' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_is_registered_returns_false_for_unregistered() {
+ $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_registered_returns_connector_data() {
+ $this->registry->register( 'my_connector', self::$default_args );
+
+ $result = $this->registry->get_registered( 'my_connector' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_registered_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' );
+
+ $result = $this->registry->get_registered( 'nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_all_registered_returns_all_connectors() {
+ $this->registry->register( 'first', self::$default_args );
+
+ $args = self::$default_args;
+ $args['name'] = 'Second Provider';
+ $this->registry->register( 'second', $args );
+
+ $all = $this->registry->get_all_registered();
+
+ $this->assertCount( 2, $all );
+ $this->assertArrayHasKey( 'first', $all );
+ $this->assertArrayHasKey( 'second', $all );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_all_registered_returns_empty_when_none() {
+ $this->assertSame( array(), $this->registry->get_all_registered() );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_removes_connector() {
+ $this->registry->register( 'to_remove', self::$default_args );
+
+ $result = $this->registry->unregister( 'to_remove' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Provider', $result['name'] );
+ $this->assertFalse( $this->registry->is_registered( 'to_remove' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' );
+
+ $result = $this->registry->unregister( 'nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_instance_returns_registry() {
+ $instance = WP_Connector_Registry::get_instance();
+
+ $this->assertInstanceOf( WP_Connector_Registry::class, $instance );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_instance_returns_same_instance() {
+ $instance1 = WP_Connector_Registry::get_instance();
+ $instance2 = WP_Connector_Registry::get_instance();
+
+ $this->assertSame( $instance1, $instance2 );
+ }
+}
diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
index f2d0aa68ee0e1..2a7ce199fa777 100644
--- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
+++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
@@ -115,4 +115,16 @@ public function test_includes_registered_provider_from_registry() {
$this->assertNull( $mock['authentication']['credentials_url'] );
$this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] );
}
+
+ /**
+ * @ticket 64730
+ */
+ public function test_connectors_are_sorted_alphabetically() {
+ $connectors = _wp_connectors_get_connector_settings();
+ $keys = array_keys( $connectors );
+ $sorted = $keys;
+ sort( $sorted );
+
+ $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' );
+ }
}
diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php
new file mode 100644
index 0000000000000..1e05a68a58609
--- /dev/null
+++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php
@@ -0,0 +1,157 @@
+ 'Test Connector',
+ 'description' => 'A test connector.',
+ 'type' => 'ai_provider',
+ 'authentication' => array(
+ 'method' => 'api_key',
+ 'credentials_url' => 'https://example.com/keys',
+ ),
+ );
+ }
+
+ /**
+ * Helper to simulate the wp_connectors_init action for registration.
+ *
+ * @param callable $callback The registration callback to run.
+ */
+ private function simulate_doing_wp_connectors_init_action( callable $callback ): void {
+ global $wp_current_filter;
+ $wp_current_filter[] = 'wp_connectors_init';
+ $callback();
+ array_pop( $wp_current_filter );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_fails_outside_action() {
+ $this->setExpectedIncorrectUsage( 'wp_register_connector' );
+
+ $result = wp_register_connector( 'outside_action', self::$default_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_register_succeeds_during_action() {
+ $result = null;
+
+ $this->simulate_doing_wp_connectors_init_action(
+ function () use ( &$result ) {
+ $result = wp_register_connector( 'during_action', self::$default_args );
+ }
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Test Connector', $result['name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_has_connector_returns_true_for_default() {
+ // Default connectors are registered via wp_connectors_init.
+ $this->assertTrue( wp_has_connector( 'openai' ) );
+ $this->assertTrue( wp_has_connector( 'google' ) );
+ $this->assertTrue( wp_has_connector( 'anthropic' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_has_connector_returns_false_for_unregistered() {
+ $this->assertFalse( wp_has_connector( 'nonexistent_provider' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connector_returns_data_for_default() {
+ $connector = wp_get_connector( 'openai' );
+
+ $this->assertIsArray( $connector );
+ $this->assertSame( 'OpenAI', $connector['name'] );
+ $this->assertSame( 'ai_provider', $connector['type'] );
+ $this->assertSame( 'api_key', $connector['authentication']['method'] );
+ $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connector_returns_null_for_unregistered() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' );
+
+ $result = wp_get_connector( 'nonexistent_provider' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_get_connectors_returns_all_defaults() {
+ $connectors = wp_get_connectors();
+
+ $this->assertArrayHasKey( 'openai', $connectors );
+ $this->assertArrayHasKey( 'google', $connectors );
+ $this->assertArrayHasKey( 'anthropic', $connectors );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_removes_connector() {
+ $this->simulate_doing_wp_connectors_init_action(
+ function () {
+ wp_register_connector( 'to_remove', self::$default_args );
+ }
+ );
+
+ $this->assertTrue( wp_has_connector( 'to_remove' ) );
+
+ $result = wp_unregister_connector( 'to_remove' );
+
+ $this->assertIsArray( $result );
+ $this->assertFalse( wp_has_connector( 'to_remove' ) );
+ }
+
+ /**
+ * @ticket 64791
+ */
+ public function test_unregister_returns_null_for_nonexistent() {
+ $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' );
+
+ $result = wp_unregister_connector( 'nonexistent' );
+
+ $this->assertNull( $result );
+ }
+}
diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php
index ef9e72e6a6724..dd79885d2b16d 100644
--- a/tests/phpunit/tests/rest-api/rest-settings-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php
@@ -120,10 +120,6 @@ public function test_get_items() {
'default_comment_status',
'site_icon', // Registered in wp-includes/blocks/site-logo.php
'wp_enable_real_time_collaboration',
- // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php.
- 'connectors_ai_anthropic_api_key',
- 'connectors_ai_google_api_key',
- 'connectors_ai_openai_api_key',
);
if ( ! is_multisite() ) {
diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js
index a8e8c6280600c..b9a143d12d95c 100644
--- a/tests/qunit/fixtures/wp-api-generated.js
+++ b/tests/qunit/fixtures/wp-api-generated.js
@@ -11066,24 +11066,6 @@ mockedApiResponse.Schema = {
"PATCH"
],
"args": {
- "connectors_ai_anthropic_api_key": {
- "title": "Anthropic API Key",
- "description": "API key for the Anthropic AI provider.",
- "type": "string",
- "required": false
- },
- "connectors_ai_google_api_key": {
- "title": "Google API Key",
- "description": "API key for the Google AI provider.",
- "type": "string",
- "required": false
- },
- "connectors_ai_openai_api_key": {
- "title": "OpenAI API Key",
- "description": "API key for the OpenAI AI provider.",
- "type": "string",
- "required": false
- },
"title": {
"title": "Title",
"description": "Site title.",
@@ -14762,9 +14744,6 @@ mockedApiResponse.CommentModel = {
};
mockedApiResponse.settings = {
- "connectors_ai_anthropic_api_key": "",
- "connectors_ai_google_api_key": "",
- "connectors_ai_openai_api_key": "",
"title": "Test Blog",
"description": "",
"url": "http://example.org",