-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Connectors: Dynamically register providers from WP AI Client registry #11080
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
base: trunk
Are you sure you want to change the base?
Changes from all commits
a6ee0ac
8d957b6
281d5d0
db1140f
3c8dad3
0186161
3d72894
a67e433
4e98cb8
0238103
e06403b
3c03732
dac7619
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 |
|---|---|---|
|
|
@@ -60,7 +60,7 @@ function _wp_connectors_mask_api_key( string $key ): string { | |
| * @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 { | ||
| function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { | ||
| try { | ||
| $registry = AiClient::defaultRegistry(); | ||
|
|
||
|
|
@@ -109,55 +109,127 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca | |
| } | ||
|
|
||
| /** | ||
| * Gets the registered connector provider settings. | ||
| * Gets the registered connector 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. | ||
| * @return array { | ||
| * Connector settings keyed by connector ID. | ||
| * | ||
| * @type array ...$0 { | ||
| * Data for a single connector. | ||
| * | ||
| * @type string $name The connector's display name. | ||
| * @type string $description The connector's description. | ||
| * @type string $type The connector type. Currently, only 'ai_provider' is supported. | ||
| * @type array $plugin Optional. Plugin data for install/activate UI. | ||
| * @type string $slug The WordPress.org plugin slug. | ||
| * } | ||
| * @type array $authentication { | ||
| * Authentication configuration. When method is 'api_key', includes | ||
| * credentials_url and setting_name. When 'none', only method is present. | ||
| * | ||
| * @type string $method The authentication method: 'api_key' or 'none'. | ||
| * @type string|null $credentials_url Optional. URL where users can obtain API credentials. | ||
| * @type string $setting_name Optional. The setting name for the API key. | ||
| * } | ||
| * } | ||
| * } | ||
| */ | ||
| function _wp_connectors_get_provider_settings(): array { | ||
| $providers = array( | ||
| function _wp_connectors_get_connector_settings(): array { | ||
| $connectors = array( | ||
| 'google' => array( | ||
|
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. Can we sort these alphabetically? Just to be neutral. should be Anthropic, Google, OpenAI |
||
| 'name' => 'Google', | ||
| 'name' => 'Google', | ||
| 'description' => __( 'Text and image generation with Gemini and Imagen.' ), | ||
| 'type' => 'ai_provider', | ||
| 'plugin' => array( | ||
| 'slug' => 'ai-provider-for-google', | ||
| ), | ||
| 'authentication' => array( | ||
| 'method' => 'api_key', | ||
| 'credentials_url' => 'https://aistudio.google.com/api-keys', | ||
| ), | ||
| ), | ||
| 'openai' => array( | ||
| 'name' => 'OpenAI', | ||
| 'name' => 'OpenAI', | ||
| 'description' => __( 'Text and image generation with GPT and Dall-E.' ), | ||
| 'type' => 'ai_provider', | ||
| 'plugin' => array( | ||
| 'slug' => 'ai-provider-for-openai', | ||
| ), | ||
| 'authentication' => array( | ||
| 'method' => 'api_key', | ||
| 'credentials_url' => 'https://platform.openai.com/api-keys', | ||
| ), | ||
| ), | ||
| 'anthropic' => array( | ||
| 'name' => 'Anthropic', | ||
| 'name' => 'Anthropic', | ||
| 'description' => __( 'Text generation with Claude.' ), | ||
| 'type' => 'ai_provider', | ||
| 'plugin' => array( | ||
| 'slug' => 'ai-provider-for-anthropic', | ||
| ), | ||
| 'authentication' => array( | ||
| 'method' => 'api_key', | ||
| 'credentials_url' => 'https://platform.claude.com/settings/keys', | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| $provider_settings = array(); | ||
| foreach ( $providers as $provider => $data ) { | ||
| $setting_name = "connectors_ai_{$provider}_api_key"; | ||
| $registry = AiClient::defaultRegistry(); | ||
|
|
||
| $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 : ''; | ||
| }, | ||
| ); | ||
| foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { | ||
| $provider_class_name = $registry->getProviderClassName( $connector_id ); | ||
| $provider_metadata = $provider_class_name::metadata(); | ||
|
|
||
| $auth_method = $provider_metadata->getAuthenticationMethod(); | ||
| $is_api_key = null !== $auth_method && $auth_method->isApiKey(); | ||
|
|
||
| if ( $is_api_key ) { | ||
| $credentials_url = $provider_metadata->getCredentialsUrl(); | ||
| $authentication = array( | ||
| 'method' => 'api_key', | ||
| 'credentials_url' => $credentials_url ? $credentials_url : null, | ||
| ); | ||
| } else { | ||
| $authentication = array( 'method' => 'none' ); | ||
| } | ||
|
|
||
| $name = $provider_metadata->getName(); | ||
| $description = $provider_metadata->getDescription(); | ||
|
|
||
| if ( isset( $connectors[ $connector_id ] ) ) { | ||
| // Override fields with non-empty registry values. | ||
| if ( $name ) { | ||
| $connectors[ $connector_id ]['name'] = $name; | ||
| } | ||
| if ( $description ) { | ||
| $connectors[ $connector_id ]['description'] = $description; | ||
| } | ||
| // Always update auth method; keep existing credentials_url as fallback. | ||
| $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; | ||
| if ( ! empty( $authentication['credentials_url'] ) ) { | ||
| $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; | ||
| } | ||
| } else { | ||
| $connectors[ $connector_id ] = array( | ||
| 'name' => $name ? $name : ucwords( $connector_id ), | ||
| 'description' => $description ? $description : '', | ||
| 'type' => 'ai_provider', | ||
| 'authentication' => $authentication, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Add setting_name for connectors that use API key authentication. | ||
| foreach ( $connectors as $connector_id => $connector ) { | ||
| if ( 'api_key' === $connector['authentication']['method'] ) { | ||
| $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; | ||
| } | ||
| } | ||
| return $provider_settings; | ||
|
|
||
| return $connectors; | ||
|
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. Related to the above: We should probably sort alphabetically by key here too, so that any registered providers are also integrated properly into the list. Alternatively, if we want to keep the primary / "official" ones first, we could keep them first, and only sort any additional ones to be alphabetically listed after that. 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. +1 to sorting all, and not just the unofficial ones. |
||
| } | ||
|
|
||
| /** | ||
|
|
@@ -181,10 +253,6 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE | |
| return $response; | ||
| } | ||
|
|
||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return $response; | ||
| } | ||
|
|
||
| $fields = $request->get_param( '_fields' ); | ||
| if ( ! $fields ) { | ||
| return $response; | ||
|
|
@@ -201,17 +269,23 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE | |
| return $response; | ||
| } | ||
|
|
||
| foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { | ||
| foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { | ||
| $auth = $connector_data['authentication']; | ||
| if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $setting_name = $auth['setting_name']; | ||
| if ( ! in_array( $setting_name, $requested, true ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); | ||
| $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); | ||
| if ( '' === $real_key ) { | ||
| continue; | ||
| } | ||
|
|
||
| if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) { | ||
| if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { | ||
| $data[ $setting_name ] = 'invalid_key'; | ||
| } | ||
| } | ||
|
|
@@ -228,27 +302,45 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE | |
| * @access private | ||
| */ | ||
| function _wp_register_default_connector_settings(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return; | ||
| } | ||
| foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { | ||
| $auth = $connector_data['authentication']; | ||
| if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { | ||
|
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. The Probably makes sense anyway, because we only really support these at the moment - prevents future errors by accident. |
||
| continue; | ||
| } | ||
|
|
||
| foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { | ||
| $setting_name = $auth['setting_name']; | ||
| register_setting( | ||
| 'connectors', | ||
| $setting_name, | ||
| array( | ||
| 'type' => 'string', | ||
| 'label' => $config['label'], | ||
| 'description' => $config['description'], | ||
| 'label' => sprintf( | ||
| /* translators: %s: AI provider name. */ | ||
| __( '%s API Key' ), | ||
| $connector_data['name'] | ||
| ), | ||
| 'description' => sprintf( | ||
| /* translators: %s: AI provider name. */ | ||
| __( 'API key for the %s AI provider.' ), | ||
| $connector_data['name'] | ||
| ), | ||
| 'default' => '', | ||
| 'show_in_rest' => true, | ||
| 'sanitize_callback' => $config['sanitize'], | ||
| 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { | ||
| $value = sanitize_text_field( $value ); | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
|
|
||
| $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); | ||
| return true === $valid ? $value : ''; | ||
| }, | ||
| ) | ||
| ); | ||
| add_filter( "option_{$setting_name}", $config['mask'] ); | ||
| add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); | ||
| } | ||
| } | ||
| add_action( 'init', '_wp_register_default_connector_settings' ); | ||
| add_action( 'init', '_wp_register_default_connector_settings', 20 ); | ||
|
|
||
| /** | ||
| * Passes stored connector API keys to the WP AI client. | ||
|
|
@@ -257,24 +349,68 @@ function _wp_register_default_connector_settings(): void { | |
| * @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'] ) ) { | ||
| foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { | ||
| if ( 'ai_provider' !== $connector_data['type'] ) { | ||
| continue; | ||
| } | ||
|
|
||
| $auth = $connector_data['authentication']; | ||
| if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); | ||
| if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $config['provider'], | ||
| $connector_id, | ||
| new ApiKeyRequestAuthentication( $api_key ) | ||
| ); | ||
| } | ||
| } catch ( Exception $e ) { | ||
| wp_trigger_error( __FUNCTION__, $e->getMessage() ); | ||
| } | ||
| } | ||
| add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' ); | ||
| add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); | ||
|
|
||
| /** | ||
| * Exposes connector settings to the connectors-wp-admin script module. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @param array $data Existing script module data. | ||
| * @return array Script module data with connectors added. | ||
| */ | ||
| function _wp_connectors_get_connector_script_module_data( array $data ): array { | ||
| $connectors = array(); | ||
| foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { | ||
| $auth = $connector_data['authentication']; | ||
| $auth_out = array( 'method' => $auth['method'] ); | ||
|
|
||
| if ( 'api_key' === $auth['method'] ) { | ||
| $auth_out['settingName'] = $auth['setting_name'] ?? ''; | ||
| $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; | ||
| } | ||
|
|
||
| $connector_out = array( | ||
| 'name' => $connector_data['name'], | ||
| 'description' => $connector_data['description'], | ||
| 'type' => $connector_data['type'], | ||
| 'authentication' => $auth_out, | ||
| ); | ||
|
|
||
| if ( ! empty( $connector_data['plugin'] ) ) { | ||
| $connector_out['plugin'] = $connector_data['plugin']; | ||
| } | ||
|
|
||
| $connectors[ $connector_id ] = $connector_out; | ||
| } | ||
| $data['connectors'] = $connectors; | ||
| return $data; | ||
| } | ||
| add_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious: are we expecting other type of
_wp_connectors_*API keys? we're going to need to handle?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not yet - or at least there is not generally applicable way to validate API keys. This function only exists because for AI provider API keys specifically, we can validate them by using the PHP AI Client SDK abstraction.