Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 195 additions & 59 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function _wp_connectors_mask_api_key( string $key ): string {
* @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_api_key_valid( string $key, string $provider_id ): ?bool {
function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: are we expecting other type of _wp_connectors_* API keys? we're going to need to handle?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not yet - or at least there is not generally applicable way to validate API keys. This function only exists because for AI provider API keys specifically, we can validate them by using the PHP AI Client SDK abstraction.

try {
$registry = AiClient::defaultRegistry();

Expand Down Expand Up @@ -109,55 +109,127 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca
}

/**
* Gets the registered connector provider settings.
* Gets the registered connector settings.
*
* @since 7.0.0
* @access private
*
* @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name.
* @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_provider_settings(): array {
$providers = array(
function _wp_connectors_get_connector_settings(): array {
$connectors = array(
'google' => array(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we sort these alphabetically? Just to be neutral. should be Anthropic, Google, OpenAI

'name' => 'Google',
'name' => 'Google',
'description' => __( 'Text and image generation with Gemini and Imagen.' ),
'type' => 'ai_provider',
'plugin' => array(
'slug' => 'ai-provider-for-google',
),
'authentication' => array(
'method' => 'api_key',
'credentials_url' => 'https://aistudio.google.com/api-keys',
),
),
'openai' => array(
'name' => 'OpenAI',
'name' => 'OpenAI',
'description' => __( 'Text and image generation with GPT and Dall-E.' ),
'type' => 'ai_provider',
'plugin' => array(
'slug' => 'ai-provider-for-openai',
),
'authentication' => array(
'method' => 'api_key',
'credentials_url' => 'https://platform.openai.com/api-keys',
),
),
'anthropic' => array(
'name' => 'Anthropic',
'name' => 'Anthropic',
'description' => __( 'Text generation with Claude.' ),
'type' => 'ai_provider',
'plugin' => array(
'slug' => 'ai-provider-for-anthropic',
),
'authentication' => array(
'method' => 'api_key',
'credentials_url' => 'https://platform.claude.com/settings/keys',
),
),
);

$provider_settings = array();
foreach ( $providers as $provider => $data ) {
$setting_name = "connectors_ai_{$provider}_api_key";
$registry = AiClient::defaultRegistry();

$provider_settings[ $setting_name ] = array(
'provider' => $provider,
'label' => sprintf(
/* translators: %s: AI provider name. */
__( '%s API Key' ),
$data['name']
),
'description' => sprintf(
/* translators: %s: AI provider name. */
__( 'API key for the %s AI provider.' ),
$data['name']
),
'mask' => '_wp_connectors_mask_api_key',
'sanitize' => static function ( string $value ) use ( $provider ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_api_key_valid( $value, $provider );
return true === $valid ? $value : '';
},
);
foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
$provider_class_name = $registry->getProviderClassName( $connector_id );
$provider_metadata = $provider_class_name::metadata();

$auth_method = $provider_metadata->getAuthenticationMethod();
$is_api_key = null !== $auth_method && $auth_method->isApiKey();

if ( $is_api_key ) {
$credentials_url = $provider_metadata->getCredentialsUrl();
$authentication = array(
'method' => 'api_key',
'credentials_url' => $credentials_url ? $credentials_url : null,
);
} else {
$authentication = array( 'method' => 'none' );
}

$name = $provider_metadata->getName();
$description = $provider_metadata->getDescription();

if ( isset( $connectors[ $connector_id ] ) ) {
// Override fields with non-empty registry values.
if ( $name ) {
$connectors[ $connector_id ]['name'] = $name;
}
if ( $description ) {
$connectors[ $connector_id ]['description'] = $description;
}
// Always update auth method; keep existing credentials_url as fallback.
$connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
if ( ! empty( $authentication['credentials_url'] ) ) {
$connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
}
} else {
$connectors[ $connector_id ] = array(
'name' => $name ? $name : ucwords( $connector_id ),
'description' => $description ? $description : '',
'type' => 'ai_provider',
'authentication' => $authentication,
);
}
}

// 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";
}
}
return $provider_settings;

return $connectors;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the above: We should probably sort alphabetically by key here too, so that any registered providers are also integrated properly into the list.

Alternatively, if we want to keep the primary / "official" ones first, we could keep them first, and only sort any additional ones to be alphabetically listed after that.

Copy link

@justlevine justlevine Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to sorting all, and not just the unofficial ones.

}

/**
Expand All @@ -181,10 +253,6 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
return $response;
}

if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return $response;
}

$fields = $request->get_param( '_fields' );
if ( ! $fields ) {
return $response;
Expand All @@ -201,17 +269,23 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
return $response;
}

foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
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;
}

$setting_name = $auth['setting_name'];
if ( ! in_array( $setting_name, $requested, true ) ) {
continue;
}

$real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
$real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
if ( '' === $real_key ) {
continue;
}

if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
$data[ $setting_name ] = 'invalid_key';
}
}
Expand All @@ -228,27 +302,45 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
* @access private
*/
function _wp_register_default_connector_settings(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitize_callback and the description below are also only correct for AI providers. So I think here we need to add the check for ai_provider type too.

Probably makes sense anyway, because we only really support these at the moment - prevents future errors by accident.

continue;
}

foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
$setting_name = $auth['setting_name'];
register_setting(
'connectors',
$setting_name,
array(
'type' => 'string',
'label' => $config['label'],
'description' => $config['description'],
'label' => sprintf(
/* translators: %s: AI provider name. */
__( '%s API Key' ),
$connector_data['name']
),
'description' => sprintf(
/* translators: %s: AI provider name. */
__( 'API key for the %s AI provider.' ),
$connector_data['name']
),
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
return true === $valid ? $value : '';
},
)
);
add_filter( "option_{$setting_name}", $config['mask'] );
add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
}
}
add_action( 'init', '_wp_register_default_connector_settings' );
add_action( 'init', '_wp_register_default_connector_settings', 20 );

/**
* Passes stored connector API keys to the WP AI client.
Expand All @@ -257,24 +349,68 @@ function _wp_register_default_connector_settings(): void {
* @access private
*/
function _wp_connectors_pass_default_keys_to_ai_client(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}
try {
$registry = AiClient::defaultRegistry();
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
$api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) {
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
if ( 'ai_provider' !== $connector_data['type'] ) {
continue;
}

$auth = $connector_data['authentication'];
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}

$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
continue;
}

$registry->setProviderRequestAuthentication(
$config['provider'],
$connector_id,
new ApiKeyRequestAuthentication( $api_key )
);
}
} catch ( Exception $e ) {
wp_trigger_error( __FUNCTION__, $e->getMessage() );
}
}
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );

/**
* Exposes connector settings to the connectors-wp-admin script module.
*
* @since 7.0.0
* @access private
*
* @param array $data Existing script module data.
* @return array Script module data with connectors added.
*/
function _wp_connectors_get_connector_script_module_data( array $data ): array {
$connectors = array();
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
$auth_out = array( 'method' => $auth['method'] );

if ( 'api_key' === $auth['method'] ) {
$auth_out['settingName'] = $auth['setting_name'] ?? '';
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
}

$connector_out = array(
'name' => $connector_data['name'],
'description' => $connector_data['description'],
'type' => $connector_data['type'],
'authentication' => $auth_out,
);

if ( ! empty( $connector_data['plugin'] ) ) {
$connector_out['plugin'] = $connector_data['plugin'];
}

$connectors[ $connector_id ] = $connector_out;
}
$data['connectors'] = $connectors;
return $data;
}
add_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
12 changes: 12 additions & 0 deletions tests/phpunit/includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,18 @@ function _unhook_font_registration() {
}
tests_add_filter( 'init', '_unhook_font_registration', 1000 );

/**
* After the init action has been run once, trying to re-register connector settings can cause
* duplicate registrations. To avoid this, unhook the connector registration functions.
*
* @since 7.0.0
*/
function _unhook_connector_registration() {
remove_action( 'init', '_wp_register_default_connector_settings', 20 );
remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
}
tests_add_filter( 'init', '_unhook_connector_registration', 1000 );

/**
* Before the abilities API categories init action runs, unhook the core ability
* categories registration function to prevent core categories from being registered
Expand Down
13 changes: 13 additions & 0 deletions tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,17 @@ private static function register_mock_connectors_provider(): void {
private static function set_mock_provider_configured( bool $is_configured ): void {
Mock_Connectors_Test_Provider_Availability::$is_configured = $is_configured;
}

/**
* Unregisters the mock provider's connector setting.
*
* Reverses the side effect of _wp_register_default_connector_settings()
* for the mock provider so that subsequent test classes start with a clean slate.
* Must be called from tear_down_after_class() after running tests.
*/
private static function unregister_mock_connector_setting(): void {
$setting_name = 'connectors_ai_mock_connectors_test_api_key';
unregister_setting( 'connectors', $setting_name );
remove_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
}
}
Loading
Loading