Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
313 changes: 313 additions & 0 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
<?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 ) ) {
_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 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.
* @return bool True if the key was set successfully, false otherwise.
*/
function _wp_connectors_set_provider_api_key( 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 false;
}

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

return true;
} catch ( Exception $e ) {
wp_trigger_error( __FUNCTION__, $e->getMessage() );
return false;
}
}

/**
* 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(
'slug' => 'gemini',
'name' => 'Gemini',
),
'openai' => array(
'slug' => 'openai',
'name' => 'OpenAI',
),
'anthropic' => array(
'slug' => 'anthropic',
'name' => 'Anthropic',
),
);

$provider_settings = array();
foreach ( $providers as $provider => $data ) {
$setting_name = "connectors_{$data['slug']}_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
*/
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_provider_settings() as $setting_name => $config ) {
register_setting(
'connectors',
$setting_name,
array(
'type' => 'string',
Comment thread
gziolo marked this conversation as resolved.
'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
*/
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_provider_settings() as $setting_name => $config ) {
$api_key = _wp_connectors_get_real_api_key( $setting_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
46 changes: 46 additions & 0 deletions tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
/**
* Tests for _wp_connectors_get_provider_settings().
*
* @group connectors
* @covers ::_wp_connectors_get_provider_settings
*/
class Tests_Connectors_WpConnectorsGetProviderSettings extends WP_UnitTestCase {

/**
* @ticket 64730
*/
public function test_returns_expected_provider_keys() {
$settings = _wp_connectors_get_provider_settings();

$this->assertArrayHasKey( 'connectors_gemini_api_key', $settings );
$this->assertArrayHasKey( 'connectors_openai_api_key', $settings );
$this->assertArrayHasKey( 'connectors_anthropic_api_key', $settings );
$this->assertCount( 3, $settings );
}

/**
* @ticket 64730
*/
public function test_each_setting_has_required_fields() {
$settings = _wp_connectors_get_provider_settings();
$required_keys = array( 'provider', 'label', 'description', 'mask', 'sanitize' );

foreach ( $settings as $setting_name => $config ) {
foreach ( $required_keys as $key ) {
$this->assertArrayHasKey( $key, $config, "Setting '{$setting_name}' is missing '{$key}'." );
}
}
}

/**
* @ticket 64730
*/
public function test_provider_values_match_expected() {
$settings = _wp_connectors_get_provider_settings();

$this->assertSame( 'google', $settings['connectors_gemini_api_key']['provider'] );
$this->assertSame( 'openai', $settings['connectors_openai_api_key']['provider'] );
$this->assertSame( 'anthropic', $settings['connectors_anthropic_api_key']['provider'] );
}
}
Loading
Loading