Skip to content
Closed
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
260 changes: 199 additions & 61 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 {
try {
$registry = AiClient::defaultRegistry();

Expand Down Expand Up @@ -109,55 +109,129 @@ 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(
'anthropic' => array(
'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',
),
),
'google' => array(
'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',
),
'anthropic' => array(
'name' => 'Anthropic',
'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',
),
),
);

$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,
);
}
}
return $provider_settings;

ksort( $connectors );

// 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 $connectors;
}

/**
Expand All @@ -181,10 +255,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 +271,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 +304,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 ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
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 +351,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