-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Connectors: Backport Gutenberg PR #75833 PHP integration #11056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
a931561
409f90f
e26814d
81a4100
31ab71c
734a6b3
a9539cf
9920293
0625624
4c380a9
21201c5
0b6f6c1
b9eccfe
52fbdca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| */ | ||
|
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 | ||
|
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; | ||
|
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 | ||
| */ | ||
|
gziolo marked this conversation as resolved.
|
||
| function _wp_register_default_connector_settings(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
|
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', | ||
|
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 | ||
| */ | ||
|
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' ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11064,6 +11064,24 @@ mockedApiResponse.Schema = { | |
| "PATCH" | ||
| ], | ||
| "args": { | ||
| "connectors_gemini_api_key": { | ||
| "title": "Gemini API Key", | ||
| "description": "API key for the Gemini AI provider.", | ||
| "type": "string", | ||
| "required": false | ||
| }, | ||
| "connectors_openai_api_key": { | ||
| "title": "OpenAI API Key", | ||
| "description": "API key for the OpenAI AI provider.", | ||
| "type": "string", | ||
| "required": false | ||
| }, | ||
| "connectors_anthropic_api_key": { | ||
| "title": "Anthropic API Key", | ||
| "description": "API key for the Anthropic AI provider.", | ||
| "type": "string", | ||
| "required": false | ||
| }, | ||
| "title": { | ||
| "title": "Title", | ||
| "description": "Site title.", | ||
|
|
@@ -14634,6 +14652,9 @@ mockedApiResponse.CommentModel = { | |
| }; | ||
|
|
||
| mockedApiResponse.settings = { | ||
| "connectors_gemini_api_key": "", | ||
| "connectors_openai_api_key": "", | ||
| "connectors_anthropic_api_key": "", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know if that matters that much, but we use Claude in the UI instead of Anthropic, similar to Gemini instead of Google. We could be more consistent, but it isn't a blocker to me.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great catch, I missed this. Actually, I think we should use Google, Anthropic, and OpenAI in the code. I'm fine using Claude and Gemini in the UI because that's probably more intuitive to many users, but the providers are Anthropic and Google - no question there. So I think on the code level we should consistently go with those names, i.e. the company names.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is exactly what @jorgefilipecosta did in 0b6f6c1, plus he prefixed it with
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like that commit also changed the user-facing names though? I'm personally fine with that, but on the Gutenberg PR it was discussed to use Claude and Gemini for the names in the UI, because more people know these names in relation to AI. We should still use Anthropic and Google everywhere in the code, but potentially the user-facing name can be adjusted as such. Since that was discussed on the Gutenberg PR, I wouldn't want to "silently" override that decision again here.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #11080 will make it easier to control in the future the display name for every provider while keeping the code name anthropic, google and openai. |
||
| "title": "Test Blog", | ||
| "description": "", | ||
| "url": "http://example.org", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.