Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://develop.svn.wordpress.org/trunk"
},
"gutenberg": {
"ref": "23b566c72e9c4a36219ef5d6e62890f05551f6cb"
"ref": "336a47b80b566256ce5035cae56b2ab16f583dad"
Comment thread
gziolo marked this conversation as resolved.
Outdated
},
"engines": {
"node": ">=20.10.0",
Expand Down
369 changes: 369 additions & 0 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
<?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
*/
Comment thread
gziolo marked this conversation as resolved.
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
Comment thread
gziolo marked this conversation as resolved.
);
}
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 ) ) {
return null;
}

$registry->setProviderRequestAuthentication(
$provider_id,
new ApiKeyRequestAuthentication( $key )
);

return $registry->isProviderConfigured( $provider_id );
} catch ( \Error $e ) {
Comment thread
gziolo marked this conversation as resolved.
Outdated
return null;
Comment thread
gziolo marked this conversation as resolved.
}
}

/**
* Sets API key authentication for a provider in the WP AI Client registry.
*
* @since 7.0.0
* @access private
*
* @param string $key The API key.
* @param string $provider_id The WP AI client provider ID.
*/
function _wp_connectors_set_provider_api_key( string $key, string $provider_id ): void {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this function should return bool, to have an idea whether it worked or not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Updated the method in 31ab71c. We might want to revisit this method if we are looking at supporting multiple auth methods. In that case, we might want something more like:

function _wp_connectors_set_provider_auth( string $provider_id, string $auth_type, array $auth_params ): void {

This way we would have a general purpose util that devs can use elsewhere, too.

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

if ( ! $registry->hasProvider( $provider_id ) ) {
return;
Comment thread
gziolo marked this conversation as resolved.
Outdated
}

$registry->setProviderRequestAuthentication(
$provider_id,
new ApiKeyRequestAuthentication( $key )
);
} catch ( \Error $e ) {
Comment thread
gziolo marked this conversation as resolved.
Outdated
// WP AI Client not available.
Comment thread
gziolo marked this conversation as resolved.
Outdated
}
}

/**
* 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;
}

/**
* Masks the Gemini API key on read.
*
* @since 7.0.0
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _wp_connectors_mask_gemini_api_key( string $value ): string {
if ( '' === $value ) {
return $value;
}

return _wp_connectors_mask_api_key( $value );
}

/**
* Sanitizes and validates the Gemini API key before saving.
*
* @since 7.0.0
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _wp_connectors_sanitize_gemini_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_api_key_valid( $value, 'google' );
return true === $valid ? $value : '';
}

/**
* Masks the OpenAI API key on read.
*
* @since 7.0.0
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _wp_connectors_mask_openai_api_key( string $value ): string {
if ( '' === $value ) {
return $value;
}

return _wp_connectors_mask_api_key( $value );
}

/**
* Sanitizes and validates the OpenAI API key before saving.
*
* @since 7.0.0
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _wp_connectors_sanitize_openai_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_api_key_valid( $value, 'openai' );
return true === $valid ? $value : '';
}

/**
* Masks the Anthropic API key on read.
*
* @since 7.0.0
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _wp_connectors_mask_anthropic_api_key( string $value ): string {
if ( '' === $value ) {
return $value;
}

return _wp_connectors_mask_api_key( $value );
}

/**
* Sanitizes and validates the Anthropic API key before saving.
*
* @since 7.0.0
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _wp_connectors_sanitize_anthropic_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_api_key_valid( $value, 'anthropic' );
return true === $valid ? $value : '';
}
Comment thread
gziolo marked this conversation as resolved.
Outdated

/**
* Gets the provider connectors.
*
* @since 7.0.0
* @access private
*
* @return array<string, array{provider: string, mask: callable, sanitize: callable}> Connectors.
*/
function _wp_connectors_get_connectors(): array {
return array(
'connectors_gemini_api_key' => array(
'provider' => 'google',
'mask' => '_wp_connectors_mask_gemini_api_key',
'sanitize' => '_wp_connectors_sanitize_gemini_api_key',
),
'connectors_openai_api_key' => array(
'provider' => 'openai',
'mask' => '_wp_connectors_mask_openai_api_key',
'sanitize' => '_wp_connectors_sanitize_openai_api_key',
),
'connectors_anthropic_api_key' => array(
'provider' => 'anthropic',
'mask' => '_wp_connectors_mask_anthropic_api_key',
'sanitize' => '_wp_connectors_sanitize_anthropic_api_key',
),
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is fine for now (i.e. Beta 2), but as discussed earlier today we should enhance this to allow properly registering a provider with all its base information in PHP, and notably reduce the barrier of entry.

First of all, of course this will require a proper registry.

In terms of this function, we would then want to use the registry, and for the 3 ones here, they would continue to be hard-coded, but overridable by the registry. This will allow us to make updates to the provider plugins (which is more appropriate for timing, since we can't quickly ship a new Core release when some of the metadata might be worth a change), and the hard-coded Core references would only be "starting points" / "fallbacks" for when the provider plugins are not active yet.

For the AI connectors specifically, we should as much as possible rely on what the WordPress\AiClient\AiClient::defaultRegistry() already offers:

Ideally we can simply iterate through those providers and then register the connector for it based on this information. We could support a "merge" behavior, where it would still be allowed to pass in additional information for the provider that may not (yet) have a 1:1 mapping in the WordPress\AiClient provider registry.

We should be able to avoid the need to provide all these callbacks, ideally this is handled centrally in Core, based on the authenticationMethod given.

And as for React UI for the fields, there should be a built-in component that is automatically used based on the given authenticationMethod. For flexibility with more advanced processes (e.g. Jetpack might add some kind of one-button click flow for better UX), we should of course still allow using a custom render component. But for the regular AI provider connector, we need to simplify this so that a single PHP array is all that's needed.

None of this is a blocker for this PR. But wanted to capture it here.

cc @gziolo

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool, as noted in #11056 (comment), I plan to work on all the described changes. It seems like a viable path forward, which would make the dev experience for AI provider authors very compelling 💯

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I started working on follow-up in #11080.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think I sorted it out for AI providers that use api keys as auth method. I drafted a working prototype in WordPress/gutenberg#76014.

}

/**
* 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_connectors() as $option_name => $config ) {
if ( ! in_array( $option_name, $requested, true ) ) {
continue;
}

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

if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
$data[ $option_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
*/
Comment thread
gziolo marked this conversation as resolved.
function _wp_register_default_connector_settings(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
Comment thread
gziolo marked this conversation as resolved.
return;
}

foreach ( _wp_connectors_get_connectors() as $option_name => $config ) {
register_setting(
'connectors',
$option_name,
array(
'type' => 'string',
Comment thread
gziolo marked this conversation as resolved.
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
)
);
add_filter( "option_{$option_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
*/
Comment thread
gziolo marked this conversation as resolved.
function _wp_connectors_pass_default_keys_to_ai_client(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}

foreach ( _wp_connectors_get_connectors() as $option_name => $config ) {
$api_key = _wp_connectors_get_real_api_key( $option_name, $config['mask'] );
if ( '' !== $api_key ) {
_wp_connectors_set_provider_api_key( $api_key, $config['provider'] );
}
}
}
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
4 changes: 4 additions & 0 deletions tests/phpunit/tests/rest-api/rest-settings-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ 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_anthropic_api_key',
'connectors_gemini_api_key',
'connectors_openai_api_key',
);

if ( ! is_multisite() ) {
Expand Down
Loading
Loading