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
280 changes: 280 additions & 0 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
<?php
/**
* Connectors API.
*
* @package WordPress
* @subpackage Connectors
* @since 7.0.0
*/

use WordPress\AiClient\AiClient;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;

/**
* Registers the Connectors menu item under Settings.
*
* @since 7.0.0
* @access private
*/
function _wp_connectors_add_settings_menu_item(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) || ! function_exists( 'wp_connectors_wp_admin_render_page' ) ) {
return;
}

add_submenu_page(
'options-general.php',
__( 'Connectors' ),
__( 'Connectors' ),
'manage_options',
'connectors-wp-admin',
'wp_connectors_wp_admin_render_page',
1
Copy link
Member

Choose a reason for hiding this comment

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

Why the menu position is set to 1 is there any discuss regarding that somewhere?

Copy link
Member

Choose a reason for hiding this comment

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

);
}
add_action( 'admin_menu', '_wp_connectors_add_settings_menu_item' );

/**
* 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_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;
Comment on lines 77 to 88
Copy link
Member

Choose a reason for hiding this comment

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

I think for both of the return null cases, we'll want to add some kind of messaging, at least for debugging.

could use _doing_it_wrong for the first one (because that would be doing it wrong on the developer side) and wp_trigger_error for the second one.

Copy link
Member

Choose a reason for hiding this comment

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

Done with 31ab71c. I still need to look into it, because we most likely will need to pass here some generic translatable message instead of the code-generated error message.

}
}

/**
* 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 provider 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.
*/
function _wp_connectors_get_provider_settings(): array {
$providers = array(
'google' => array(
'name' => 'Google',
),
'openai' => array(
'name' => 'OpenAI',
),
'anthropic' => array(
'name' => 'Anthropic',
),
);

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

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

/**
* Validates connector API keys in the REST response when explicitly requested.
*
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
* fields via `_fields`. For each requested connector field, it validates the unmasked
* key against the provider and replaces the response value with `invalid_key` if
* validation fails.
*
* @since 7.0.0
* @access private
*
* @param WP_REST_Response $response The response object.
* @param WP_REST_Server $server The server instance.
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response The potentially modified response.
*/
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
if ( '/wp/v2/settings' !== $request->get_route() ) {
return $response;
}

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

$fields = $request->get_param( '_fields' );
if ( ! $fields ) {
return $response;
}

if ( is_array( $fields ) ) {
$requested = $fields;
} else {
$requested = array_map( 'trim', explode( ',', $fields ) );
}

$data = $response->get_data();
if ( ! is_array( $data ) ) {
return $response;
}

foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
if ( ! in_array( $setting_name, $requested, true ) ) {
continue;
}

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

if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
$data[ $setting_name ] = 'invalid_key';
}
}

$response->set_data( $data );
return $response;
}
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );

/**
* Registers default connector settings and mask/sanitize filters.
*
* @since 7.0.0
* @access private
*/
function _wp_register_default_connector_settings(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

@jorgefilipecosta, do we need this check in WP core? I bet we don't.

Copy link
Member Author

Choose a reason for hiding this comment

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

Well we don't excatly "need" but I would prefer to be defensive here, in the same way code normally checks for the inclusion of build generated functions function_exists( 'wp_connectors_wp_admin_render_page' ), AI client also seems like an external lib we had conversations in the past about some constant that makes AI client not even load etc, so I think being defensive here may be good.

Copy link
Member

Choose a reason for hiding this comment

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

WP AI Client development moves to the WP core. PHP AI Client is only treated as an external library.

return;
}

foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
register_setting(
'connectors',
$setting_name,
array(
'type' => 'string',
Copy link
Member

Choose a reason for hiding this comment

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

This works for now, but is problematic and needs to be improved before stable release: Not every credential is a single string. A connector may require two fields (e.g. ID and secret, username and password, ...). We need to abstract this in a way that it's not enforcing a single option per provider usage - or at the very least, if we use a single option, it also needs to allow for more complex data, e.g. an object / associative array with multiple fields in it.

Once we have the connector registry, I think a good idea might be to have an settings field in there that allows an array and then handles these things. Or it could even be a register_settings PHP callback.

(Again, for some predefined authentication methods like "api_key" we could auto-populate this.)

cc @gziolo

Copy link
Member

Choose a reason for hiding this comment

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

I refactored _wp_connectors_get_provider_settings(), so it generates all necessary settings for registered (hardcoded for now) providers. The current shape will allow us to iterate further while moving most of the logic to that function. I will continue working on it based on the proposed design after Beta 2.

'label' => $config['label'],
'description' => $config['description'],
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
)
);
add_filter( "option_{$setting_name}", $config['mask'] );
}
}
add_action( 'init', '_wp_register_default_connector_settings' );

/**
* Passes stored connector API keys to the WP AI client.
*
* @since 7.0.0
* @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'] ) ) {
continue;
}

$registry->setProviderRequestAuthentication(
$config['provider'],
new ApiKeyRequestAuthentication( $api_key )
);
}
} catch ( Exception $e ) {
wp_trigger_error( __FUNCTION__, $e->getMessage() );
}
}
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 . '/connectors.php';
require ABSPATH . WPINC . '/class-wp-icons-registry.php';
require ABSPATH . WPINC . '/widgets.php';
require ABSPATH . WPINC . '/class-wp-widget.php';
Expand Down
Loading
Loading