-
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 4 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,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 | ||
| */ | ||
|
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 ) ) { | ||
| return null; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $provider_id, | ||
| new ApiKeyRequestAuthentication( $key ) | ||
| ); | ||
|
|
||
| return $registry->isProviderConfigured( $provider_id ); | ||
| } catch ( \Error $e ) { | ||
|
gziolo marked this conversation as resolved.
Outdated
|
||
| 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. | ||
| */ | ||
| function _wp_connectors_set_provider_api_key( string $key, string $provider_id ): void { | ||
|
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 think this function should return
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. 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; | ||
|
gziolo marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $provider_id, | ||
| new ApiKeyRequestAuthentication( $key ) | ||
| ); | ||
| } catch ( \Error $e ) { | ||
|
gziolo marked this conversation as resolved.
Outdated
|
||
| // WP AI Client not available. | ||
|
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 : ''; | ||
| } | ||
|
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', | ||
| ), | ||
| ); | ||
|
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 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
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 We should be able to avoid the need to provide all these callbacks, ideally this is handled centrally in Core, based on the And as for React UI for the fields, there should be a built-in component that is automatically used based on the given None of this is a blocker for this PR. But wanted to capture it here. cc @gziolo
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. 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 💯
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 started working on follow-up in #11080.
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 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 | ||
| */ | ||
|
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_connectors() as $option_name => $config ) { | ||
| register_setting( | ||
| 'connectors', | ||
| $option_name, | ||
| array( | ||
| 'type' => 'string', | ||
|
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 | ||
| */ | ||
|
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' ); | ||
Uh oh!
There was an error while loading. Please reload this page.