Skip to content

Commit 3cd3d3b

Browse files
Connectors: Backport Gutenberg connectors screen.
Adds `wp-includes/connectors.php` (loaded from `wp-settings.php`) and registers a Settings > Connectors submenu when the AI client and Connectors admin page renderer are available. Registers connector API key settings in `/wp/v2/settings`, masks key values on option reads, validates keys against provider configuration, and returns `invalid_key` for explicitly requested connector fields when validation fails. Stored connector keys are also passed to the AI client registry on init. Gutenberg PR at WordPress/gutenberg#75833. Developed in #11056. Props jorgefilipecosta, gziolo, flixos90, justlevine, westonruter, jeffpaul, JasonTheAdams, audrasjb, shaunandrews, noruzzaman, mukesh27. Fixes #64730. git-svn-id: https://develop.svn.wordpress.org/trunk@61749 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 33688bb commit 3cd3d3b

File tree

8 files changed

+637
-0
lines changed

8 files changed

+637
-0
lines changed

src/wp-includes/connectors.php

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
<?php
2+
/**
3+
* Connectors API.
4+
*
5+
* @package WordPress
6+
* @subpackage Connectors
7+
* @since 7.0.0
8+
*/
9+
10+
use WordPress\AiClient\AiClient;
11+
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
12+
13+
/**
14+
* Registers the Connectors menu item under Settings.
15+
*
16+
* @since 7.0.0
17+
* @access private
18+
*/
19+
function _wp_connectors_add_settings_menu_item(): void {
20+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) || ! function_exists( 'wp_connectors_wp_admin_render_page' ) ) {
21+
return;
22+
}
23+
24+
add_submenu_page(
25+
'options-general.php',
26+
__( 'Connectors' ),
27+
__( 'Connectors' ),
28+
'manage_options',
29+
'connectors-wp-admin',
30+
'wp_connectors_wp_admin_render_page',
31+
1
32+
);
33+
}
34+
add_action( 'admin_menu', '_wp_connectors_add_settings_menu_item' );
35+
36+
/**
37+
* Masks an API key, showing only the last 4 characters.
38+
*
39+
* @since 7.0.0
40+
* @access private
41+
*
42+
* @param string $key The API key to mask.
43+
* @return string The masked key, e.g. "************fj39".
44+
*/
45+
function _wp_connectors_mask_api_key( string $key ): string {
46+
if ( strlen( $key ) <= 4 ) {
47+
return $key;
48+
}
49+
50+
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
51+
}
52+
53+
/**
54+
* Checks whether an API key is valid for a given provider.
55+
*
56+
* @since 7.0.0
57+
* @access private
58+
*
59+
* @param string $key The API key to check.
60+
* @param string $provider_id The WP AI client provider ID.
61+
* @return bool|null True if valid, false if invalid, null if unable to determine.
62+
*/
63+
function _wp_connectors_is_api_key_valid( string $key, string $provider_id ): ?bool {
64+
try {
65+
$registry = AiClient::defaultRegistry();
66+
67+
if ( ! $registry->hasProvider( $provider_id ) ) {
68+
_doing_it_wrong(
69+
__FUNCTION__,
70+
sprintf(
71+
/* translators: %s: AI provider ID. */
72+
__( 'The provider "%s" is not registered in the AI client registry.' ),
73+
$provider_id
74+
),
75+
'7.0.0'
76+
);
77+
return null;
78+
}
79+
80+
$registry->setProviderRequestAuthentication(
81+
$provider_id,
82+
new ApiKeyRequestAuthentication( $key )
83+
);
84+
85+
return $registry->isProviderConfigured( $provider_id );
86+
} catch ( Exception $e ) {
87+
wp_trigger_error( __FUNCTION__, $e->getMessage() );
88+
return null;
89+
}
90+
}
91+
92+
/**
93+
* Retrieves the real (unmasked) value of a connector API key.
94+
*
95+
* Temporarily removes the masking filter, reads the option, then re-adds it.
96+
*
97+
* @since 7.0.0
98+
* @access private
99+
*
100+
* @param string $option_name The option name for the API key.
101+
* @param callable $mask_callback The mask filter function.
102+
* @return string The real API key value.
103+
*/
104+
function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
105+
remove_filter( "option_{$option_name}", $mask_callback );
106+
$value = get_option( $option_name, '' );
107+
add_filter( "option_{$option_name}", $mask_callback );
108+
return (string) $value;
109+
}
110+
111+
/**
112+
* Gets the registered connector provider settings.
113+
*
114+
* @since 7.0.0
115+
* @access private
116+
*
117+
* @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name.
118+
*/
119+
function _wp_connectors_get_provider_settings(): array {
120+
$providers = array(
121+
'google' => array(
122+
'name' => 'Google',
123+
),
124+
'openai' => array(
125+
'name' => 'OpenAI',
126+
),
127+
'anthropic' => array(
128+
'name' => 'Anthropic',
129+
),
130+
);
131+
132+
$provider_settings = array();
133+
foreach ( $providers as $provider => $data ) {
134+
$setting_name = "connectors_ai_{$provider}_api_key";
135+
136+
$provider_settings[ $setting_name ] = array(
137+
'provider' => $provider,
138+
'label' => sprintf(
139+
/* translators: %s: AI provider name. */
140+
__( '%s API Key' ),
141+
$data['name']
142+
),
143+
'description' => sprintf(
144+
/* translators: %s: AI provider name. */
145+
__( 'API key for the %s AI provider.' ),
146+
$data['name']
147+
),
148+
'mask' => '_wp_connectors_mask_api_key',
149+
'sanitize' => static function ( string $value ) use ( $provider ): string {
150+
$value = sanitize_text_field( $value );
151+
if ( '' === $value ) {
152+
return $value;
153+
}
154+
155+
$valid = _wp_connectors_is_api_key_valid( $value, $provider );
156+
return true === $valid ? $value : '';
157+
},
158+
);
159+
}
160+
return $provider_settings;
161+
}
162+
163+
/**
164+
* Validates connector API keys in the REST response when explicitly requested.
165+
*
166+
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
167+
* fields via `_fields`. For each requested connector field, it validates the unmasked
168+
* key against the provider and replaces the response value with `invalid_key` if
169+
* validation fails.
170+
*
171+
* @since 7.0.0
172+
* @access private
173+
*
174+
* @param WP_REST_Response $response The response object.
175+
* @param WP_REST_Server $server The server instance.
176+
* @param WP_REST_Request $request The request object.
177+
* @return WP_REST_Response The potentially modified response.
178+
*/
179+
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
180+
if ( '/wp/v2/settings' !== $request->get_route() ) {
181+
return $response;
182+
}
183+
184+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
185+
return $response;
186+
}
187+
188+
$fields = $request->get_param( '_fields' );
189+
if ( ! $fields ) {
190+
return $response;
191+
}
192+
193+
if ( is_array( $fields ) ) {
194+
$requested = $fields;
195+
} else {
196+
$requested = array_map( 'trim', explode( ',', $fields ) );
197+
}
198+
199+
$data = $response->get_data();
200+
if ( ! is_array( $data ) ) {
201+
return $response;
202+
}
203+
204+
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
205+
if ( ! in_array( $setting_name, $requested, true ) ) {
206+
continue;
207+
}
208+
209+
$real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
210+
if ( '' === $real_key ) {
211+
continue;
212+
}
213+
214+
if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
215+
$data[ $setting_name ] = 'invalid_key';
216+
}
217+
}
218+
219+
$response->set_data( $data );
220+
return $response;
221+
}
222+
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
223+
224+
/**
225+
* Registers default connector settings and mask/sanitize filters.
226+
*
227+
* @since 7.0.0
228+
* @access private
229+
*/
230+
function _wp_register_default_connector_settings(): void {
231+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
232+
return;
233+
}
234+
235+
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
236+
register_setting(
237+
'connectors',
238+
$setting_name,
239+
array(
240+
'type' => 'string',
241+
'label' => $config['label'],
242+
'description' => $config['description'],
243+
'default' => '',
244+
'show_in_rest' => true,
245+
'sanitize_callback' => $config['sanitize'],
246+
)
247+
);
248+
add_filter( "option_{$setting_name}", $config['mask'] );
249+
}
250+
}
251+
add_action( 'init', '_wp_register_default_connector_settings' );
252+
253+
/**
254+
* Passes stored connector API keys to the WP AI client.
255+
*
256+
* @since 7.0.0
257+
* @access private
258+
*/
259+
function _wp_connectors_pass_default_keys_to_ai_client(): void {
260+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
261+
return;
262+
}
263+
try {
264+
$registry = AiClient::defaultRegistry();
265+
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
266+
$api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
267+
if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) {
268+
continue;
269+
}
270+
271+
$registry->setProviderRequestAuthentication(
272+
$config['provider'],
273+
new ApiKeyRequestAuthentication( $api_key )
274+
);
275+
}
276+
} catch ( Exception $e ) {
277+
wp_trigger_error( __FUNCTION__, $e->getMessage() );
278+
}
279+
}
280+
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );

src/wp-settings.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@
294294
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php';
295295
require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php';
296296
require ABSPATH . WPINC . '/ai-client.php';
297+
require ABSPATH . WPINC . '/connectors.php';
297298
require ABSPATH . WPINC . '/class-wp-icons-registry.php';
298299
require ABSPATH . WPINC . '/widgets.php';
299300
require ABSPATH . WPINC . '/class-wp-widget.php';

0 commit comments

Comments
 (0)