Skip to content

Commit a931561

Browse files
Backport connectors screen
1 parent 2cc6cad commit a931561

3 files changed

Lines changed: 371 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://develop.svn.wordpress.org/trunk"
88
},
99
"gutenberg": {
10-
"ref": "23b566c72e9c4a36219ef5d6e62890f05551f6cb"
10+
"ref": "336a47b80b566256ce5035cae56b2ab16f583dad"
1111
},
1212
"engines": {
1313
"node": ">=20.10.0",

src/wp-includes/connectors.php

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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+
return null;
69+
}
70+
71+
$registry->setProviderRequestAuthentication(
72+
$provider_id,
73+
new ApiKeyRequestAuthentication( $key )
74+
);
75+
76+
return $registry->isProviderConfigured( $provider_id );
77+
} catch ( \Error $e ) {
78+
return null;
79+
}
80+
}
81+
82+
/**
83+
* Sets API key authentication for a provider in the WP AI Client registry.
84+
*
85+
* @since 7.0.0
86+
* @access private
87+
*
88+
* @param string $key The API key.
89+
* @param string $provider_id The WP AI client provider ID.
90+
*/
91+
function _wp_connectors_set_provider_api_key( string $key, string $provider_id ): void {
92+
try {
93+
$registry = AiClient::defaultRegistry();
94+
95+
if ( ! $registry->hasProvider( $provider_id ) ) {
96+
return;
97+
}
98+
99+
$registry->setProviderRequestAuthentication(
100+
$provider_id,
101+
new ApiKeyRequestAuthentication( $key )
102+
);
103+
} catch ( \Error $e ) {
104+
// WP AI Client not available.
105+
}
106+
}
107+
108+
/**
109+
* Retrieves the real (unmasked) value of a connector API key.
110+
*
111+
* Temporarily removes the masking filter, reads the option, then re-adds it.
112+
*
113+
* @since 7.0.0
114+
* @access private
115+
*
116+
* @param string $option_name The option name for the API key.
117+
* @param callable $mask_callback The mask filter function.
118+
* @return string The real API key value.
119+
*/
120+
function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
121+
remove_filter( "option_{$option_name}", $mask_callback );
122+
$value = get_option( $option_name, '' );
123+
add_filter( "option_{$option_name}", $mask_callback );
124+
return (string) $value;
125+
}
126+
127+
/**
128+
* Masks the Gemini API key on read.
129+
*
130+
* @since 7.0.0
131+
* @access private
132+
*
133+
* @param string $value The raw option value.
134+
* @return string Masked key or empty string.
135+
*/
136+
function _wp_connectors_mask_gemini_api_key( string $value ): string {
137+
if ( '' === $value ) {
138+
return $value;
139+
}
140+
141+
return _wp_connectors_mask_api_key( $value );
142+
}
143+
144+
/**
145+
* Sanitizes and validates the Gemini API key before saving.
146+
*
147+
* @since 7.0.0
148+
* @access private
149+
*
150+
* @param string $value The new value.
151+
* @return string The sanitized value, or empty string if the key is not valid.
152+
*/
153+
function _wp_connectors_sanitize_gemini_api_key( string $value ): string {
154+
$value = sanitize_text_field( $value );
155+
if ( '' === $value ) {
156+
return $value;
157+
}
158+
159+
$valid = _wp_connectors_is_api_key_valid( $value, 'google' );
160+
return true === $valid ? $value : '';
161+
}
162+
163+
/**
164+
* Masks the OpenAI API key on read.
165+
*
166+
* @since 7.0.0
167+
* @access private
168+
*
169+
* @param string $value The raw option value.
170+
* @return string Masked key or empty string.
171+
*/
172+
function _wp_connectors_mask_openai_api_key( string $value ): string {
173+
if ( '' === $value ) {
174+
return $value;
175+
}
176+
177+
return _wp_connectors_mask_api_key( $value );
178+
}
179+
180+
/**
181+
* Sanitizes and validates the OpenAI API key before saving.
182+
*
183+
* @since 7.0.0
184+
* @access private
185+
*
186+
* @param string $value The new value.
187+
* @return string The sanitized value, or empty string if the key is not valid.
188+
*/
189+
function _wp_connectors_sanitize_openai_api_key( string $value ): string {
190+
$value = sanitize_text_field( $value );
191+
if ( '' === $value ) {
192+
return $value;
193+
}
194+
195+
$valid = _wp_connectors_is_api_key_valid( $value, 'openai' );
196+
return true === $valid ? $value : '';
197+
}
198+
199+
/**
200+
* Masks the Anthropic API key on read.
201+
*
202+
* @since 7.0.0
203+
* @access private
204+
*
205+
* @param string $value The raw option value.
206+
* @return string Masked key or empty string.
207+
*/
208+
function _wp_connectors_mask_anthropic_api_key( string $value ): string {
209+
if ( '' === $value ) {
210+
return $value;
211+
}
212+
213+
return _wp_connectors_mask_api_key( $value );
214+
}
215+
216+
/**
217+
* Sanitizes and validates the Anthropic API key before saving.
218+
*
219+
* @since 7.0.0
220+
* @access private
221+
*
222+
* @param string $value The new value.
223+
* @return string The sanitized value, or empty string if the key is not valid.
224+
*/
225+
function _wp_connectors_sanitize_anthropic_api_key( string $value ): string {
226+
$value = sanitize_text_field( $value );
227+
if ( '' === $value ) {
228+
return $value;
229+
}
230+
231+
$valid = _wp_connectors_is_api_key_valid( $value, 'anthropic' );
232+
return true === $valid ? $value : '';
233+
}
234+
235+
/**
236+
* Gets the provider connectors.
237+
*
238+
* @since 7.0.0
239+
* @access private
240+
*
241+
* @return array<string, array{provider: string, mask: callable, sanitize: callable}> Connectors.
242+
*/
243+
function _wp_connectors_get_connectors(): array {
244+
return array(
245+
'connectors_gemini_api_key' => array(
246+
'provider' => 'google',
247+
'mask' => '_wp_connectors_mask_gemini_api_key',
248+
'sanitize' => '_wp_connectors_sanitize_gemini_api_key',
249+
),
250+
'connectors_openai_api_key' => array(
251+
'provider' => 'openai',
252+
'mask' => '_wp_connectors_mask_openai_api_key',
253+
'sanitize' => '_wp_connectors_sanitize_openai_api_key',
254+
),
255+
'connectors_anthropic_api_key' => array(
256+
'provider' => 'anthropic',
257+
'mask' => '_wp_connectors_mask_anthropic_api_key',
258+
'sanitize' => '_wp_connectors_sanitize_anthropic_api_key',
259+
),
260+
);
261+
}
262+
263+
/**
264+
* Validates connector API keys in the REST response when explicitly requested.
265+
*
266+
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
267+
* fields via `_fields`. For each requested connector field, it validates the unmasked
268+
* key against the provider and replaces the response value with `invalid_key` if
269+
* validation fails.
270+
*
271+
* @since 7.0.0
272+
* @access private
273+
*
274+
* @param WP_REST_Response $response The response object.
275+
* @param WP_REST_Server $server The server instance.
276+
* @param WP_REST_Request $request The request object.
277+
* @return WP_REST_Response The potentially modified response.
278+
*/
279+
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
280+
if ( '/wp/v2/settings' !== $request->get_route() ) {
281+
return $response;
282+
}
283+
284+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
285+
return $response;
286+
}
287+
288+
$fields = $request->get_param( '_fields' );
289+
if ( ! $fields ) {
290+
return $response;
291+
}
292+
293+
if ( is_array( $fields ) ) {
294+
$requested = $fields;
295+
} else {
296+
$requested = array_map( 'trim', explode( ',', $fields ) );
297+
}
298+
299+
$data = $response->get_data();
300+
if ( ! is_array( $data ) ) {
301+
return $response;
302+
}
303+
304+
foreach ( _wp_connectors_get_connectors() as $option_name => $config ) {
305+
if ( ! in_array( $option_name, $requested, true ) ) {
306+
continue;
307+
}
308+
309+
$real_key = _wp_connectors_get_real_api_key( $option_name, $config['mask'] );
310+
if ( '' === $real_key ) {
311+
continue;
312+
}
313+
314+
if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
315+
$data[ $option_name ] = 'invalid_key';
316+
}
317+
}
318+
319+
$response->set_data( $data );
320+
return $response;
321+
}
322+
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
323+
324+
/**
325+
* Registers default connector settings and mask/sanitize filters.
326+
*
327+
* @since 7.0.0
328+
* @access private
329+
*/
330+
function _wp_register_default_connector_settings(): void {
331+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
332+
return;
333+
}
334+
335+
foreach ( _wp_connectors_get_connectors() as $option_name => $config ) {
336+
register_setting(
337+
'connectors',
338+
$option_name,
339+
array(
340+
'type' => 'string',
341+
'default' => '',
342+
'show_in_rest' => true,
343+
'sanitize_callback' => $config['sanitize'],
344+
)
345+
);
346+
add_filter( "option_{$option_name}", $config['mask'] );
347+
}
348+
}
349+
add_action( 'init', '_wp_register_default_connector_settings' );
350+
351+
/**
352+
* Passes stored connector API keys to the WP AI client.
353+
*
354+
* @since 7.0.0
355+
* @access private
356+
*/
357+
function _wp_connectors_pass_default_keys_to_ai_client(): void {
358+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
359+
return;
360+
}
361+
362+
foreach ( _wp_connectors_get_connectors() as $option_name => $config ) {
363+
$api_key = _wp_connectors_get_real_api_key( $option_name, $config['mask'] );
364+
if ( '' !== $api_key ) {
365+
_wp_connectors_set_provider_api_key( $api_key, $config['provider'] );
366+
}
367+
}
368+
}
369+
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)