Skip to content

Commit 21f3a54

Browse files
committed
Connectors: Dynamically register providers from WP AI Client registry.
Replaces `_wp_connectors_get_provider_settings()` with `_wp_connectors_get_connector_settings()` that returns a richer structure keyed by connector ID, including name, description, type, plugin slug, and an authentication sub-object (method, credentials_url, setting_name). The new function merges hardcoded defaults for featured providers (Anthropic, Google, OpenAI) with metadata from the WP AI Client registry, allowing dynamically registered providers to appear alongside built-in ones. Providers are sorted alphabetically with `ksort()`. Additionally: * Renames `_wp_connectors_is_api_key_valid()` to `_wp_connectors_is_ai_api_key_valid()`. * Adds `_wp_connectors_get_connector_script_module_data()` to expose connector settings to the `connectors-wp-admin` script module. * Includes plugin slug data for featured connectors to support install/activate UI. * Removes redundant `class_exists` checks for `AiClient`. * Runs `init` hooks at priority 20 so provider plugins registered at default priority are available. * Unhooks connector registration during tests to prevent duplicate registrations. Synced from WordPress/gutenberg#76014. Developed in WordPress#11080. Follow-up to [61749]. Props gziolo, jorgefilipecosta, justlevine, flixos90, ellatrix. Fixes #64730. git-svn-id: https://develop.svn.wordpress.org/trunk@61824 602fd350-edb4-49c9-b593-d223f7449a82
1 parent db6e7ac commit 21f3a54

File tree

7 files changed

+355
-120
lines changed

7 files changed

+355
-120
lines changed

src/wp-includes/connectors.php

Lines changed: 199 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function _wp_connectors_mask_api_key( string $key ): string {
6060
* @param string $provider_id The WP AI client provider ID.
6161
* @return bool|null True if valid, false if invalid, null if unable to determine.
6262
*/
63-
function _wp_connectors_is_api_key_valid( string $key, string $provider_id ): ?bool {
63+
function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
6464
try {
6565
$registry = AiClient::defaultRegistry();
6666

@@ -109,55 +109,129 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca
109109
}
110110

111111
/**
112-
* Gets the registered connector provider settings.
112+
* Gets the registered connector settings.
113113
*
114114
* @since 7.0.0
115115
* @access private
116116
*
117-
* @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name.
117+
* @return array {
118+
* Connector settings keyed by connector ID.
119+
*
120+
* @type array ...$0 {
121+
* Data for a single connector.
122+
*
123+
* @type string $name The connector's display name.
124+
* @type string $description The connector's description.
125+
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
126+
* @type array $plugin Optional. Plugin data for install/activate UI.
127+
* @type string $slug The WordPress.org plugin slug.
128+
* }
129+
* @type array $authentication {
130+
* Authentication configuration. When method is 'api_key', includes
131+
* credentials_url and setting_name. When 'none', only method is present.
132+
*
133+
* @type string $method The authentication method: 'api_key' or 'none'.
134+
* @type string|null $credentials_url Optional. URL where users can obtain API credentials.
135+
* @type string $setting_name Optional. The setting name for the API key.
136+
* }
137+
* }
138+
* }
118139
*/
119-
function _wp_connectors_get_provider_settings(): array {
120-
$providers = array(
140+
function _wp_connectors_get_connector_settings(): array {
141+
$connectors = array(
142+
'anthropic' => array(
143+
'name' => 'Anthropic',
144+
'description' => __( 'Text generation with Claude.' ),
145+
'type' => 'ai_provider',
146+
'plugin' => array(
147+
'slug' => 'ai-provider-for-anthropic',
148+
),
149+
'authentication' => array(
150+
'method' => 'api_key',
151+
'credentials_url' => 'https://platform.claude.com/settings/keys',
152+
),
153+
),
121154
'google' => array(
122-
'name' => 'Google',
155+
'name' => 'Google',
156+
'description' => __( 'Text and image generation with Gemini and Imagen.' ),
157+
'type' => 'ai_provider',
158+
'plugin' => array(
159+
'slug' => 'ai-provider-for-google',
160+
),
161+
'authentication' => array(
162+
'method' => 'api_key',
163+
'credentials_url' => 'https://aistudio.google.com/api-keys',
164+
),
123165
),
124166
'openai' => array(
125-
'name' => 'OpenAI',
126-
),
127-
'anthropic' => array(
128-
'name' => 'Anthropic',
167+
'name' => 'OpenAI',
168+
'description' => __( 'Text and image generation with GPT and Dall-E.' ),
169+
'type' => 'ai_provider',
170+
'plugin' => array(
171+
'slug' => 'ai-provider-for-openai',
172+
),
173+
'authentication' => array(
174+
'method' => 'api_key',
175+
'credentials_url' => 'https://platform.openai.com/api-keys',
176+
),
129177
),
130178
);
131179

132-
$provider_settings = array();
133-
foreach ( $providers as $provider => $data ) {
134-
$setting_name = "connectors_ai_{$provider}_api_key";
180+
$registry = AiClient::defaultRegistry();
135181

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-
);
182+
foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
183+
$provider_class_name = $registry->getProviderClassName( $connector_id );
184+
$provider_metadata = $provider_class_name::metadata();
185+
186+
$auth_method = $provider_metadata->getAuthenticationMethod();
187+
$is_api_key = null !== $auth_method && $auth_method->isApiKey();
188+
189+
if ( $is_api_key ) {
190+
$credentials_url = $provider_metadata->getCredentialsUrl();
191+
$authentication = array(
192+
'method' => 'api_key',
193+
'credentials_url' => $credentials_url ? $credentials_url : null,
194+
);
195+
} else {
196+
$authentication = array( 'method' => 'none' );
197+
}
198+
199+
$name = $provider_metadata->getName();
200+
$description = $provider_metadata->getDescription();
201+
202+
if ( isset( $connectors[ $connector_id ] ) ) {
203+
// Override fields with non-empty registry values.
204+
if ( $name ) {
205+
$connectors[ $connector_id ]['name'] = $name;
206+
}
207+
if ( $description ) {
208+
$connectors[ $connector_id ]['description'] = $description;
209+
}
210+
// Always update auth method; keep existing credentials_url as fallback.
211+
$connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
212+
if ( ! empty( $authentication['credentials_url'] ) ) {
213+
$connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
214+
}
215+
} else {
216+
$connectors[ $connector_id ] = array(
217+
'name' => $name ? $name : ucwords( $connector_id ),
218+
'description' => $description ? $description : '',
219+
'type' => 'ai_provider',
220+
'authentication' => $authentication,
221+
);
222+
}
159223
}
160-
return $provider_settings;
224+
225+
ksort( $connectors );
226+
227+
// Add setting_name for connectors that use API key authentication.
228+
foreach ( $connectors as $connector_id => $connector ) {
229+
if ( 'api_key' === $connector['authentication']['method'] ) {
230+
$connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";
231+
}
232+
}
233+
234+
return $connectors;
161235
}
162236

163237
/**
@@ -181,10 +255,6 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
181255
return $response;
182256
}
183257

184-
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
185-
return $response;
186-
}
187-
188258
$fields = $request->get_param( '_fields' );
189259
if ( ! $fields ) {
190260
return $response;
@@ -201,17 +271,23 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
201271
return $response;
202272
}
203273

204-
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
274+
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
275+
$auth = $connector_data['authentication'];
276+
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
277+
continue;
278+
}
279+
280+
$setting_name = $auth['setting_name'];
205281
if ( ! in_array( $setting_name, $requested, true ) ) {
206282
continue;
207283
}
208284

209-
$real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] );
285+
$real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
210286
if ( '' === $real_key ) {
211287
continue;
212288
}
213289

214-
if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) {
290+
if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
215291
$data[ $setting_name ] = 'invalid_key';
216292
}
217293
}
@@ -228,27 +304,45 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE
228304
* @access private
229305
*/
230306
function _wp_register_default_connector_settings(): void {
231-
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
232-
return;
233-
}
307+
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
308+
$auth = $connector_data['authentication'];
309+
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
310+
continue;
311+
}
234312

235-
foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) {
313+
$setting_name = $auth['setting_name'];
236314
register_setting(
237315
'connectors',
238316
$setting_name,
239317
array(
240318
'type' => 'string',
241-
'label' => $config['label'],
242-
'description' => $config['description'],
319+
'label' => sprintf(
320+
/* translators: %s: AI provider name. */
321+
__( '%s API Key' ),
322+
$connector_data['name']
323+
),
324+
'description' => sprintf(
325+
/* translators: %s: AI provider name. */
326+
__( 'API key for the %s AI provider.' ),
327+
$connector_data['name']
328+
),
243329
'default' => '',
244330
'show_in_rest' => true,
245-
'sanitize_callback' => $config['sanitize'],
331+
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
332+
$value = sanitize_text_field( $value );
333+
if ( '' === $value ) {
334+
return $value;
335+
}
336+
337+
$valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
338+
return true === $valid ? $value : '';
339+
},
246340
)
247341
);
248-
add_filter( "option_{$setting_name}", $config['mask'] );
342+
add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
249343
}
250344
}
251-
add_action( 'init', '_wp_register_default_connector_settings' );
345+
add_action( 'init', '_wp_register_default_connector_settings', 20 );
252346

253347
/**
254348
* Passes stored connector API keys to the WP AI client.
@@ -257,24 +351,68 @@ function _wp_register_default_connector_settings(): void {
257351
* @access private
258352
*/
259353
function _wp_connectors_pass_default_keys_to_ai_client(): void {
260-
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
261-
return;
262-
}
263354
try {
264355
$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'] ) ) {
356+
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
357+
if ( 'ai_provider' !== $connector_data['type'] ) {
358+
continue;
359+
}
360+
361+
$auth = $connector_data['authentication'];
362+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
363+
continue;
364+
}
365+
366+
$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
367+
if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
268368
continue;
269369
}
270370

271371
$registry->setProviderRequestAuthentication(
272-
$config['provider'],
372+
$connector_id,
273373
new ApiKeyRequestAuthentication( $api_key )
274374
);
275375
}
276376
} catch ( Exception $e ) {
277377
wp_trigger_error( __FUNCTION__, $e->getMessage() );
278378
}
279379
}
280-
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );
380+
add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
381+
382+
/**
383+
* Exposes connector settings to the connectors-wp-admin script module.
384+
*
385+
* @since 7.0.0
386+
* @access private
387+
*
388+
* @param array $data Existing script module data.
389+
* @return array Script module data with connectors added.
390+
*/
391+
function _wp_connectors_get_connector_script_module_data( array $data ): array {
392+
$connectors = array();
393+
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
394+
$auth = $connector_data['authentication'];
395+
$auth_out = array( 'method' => $auth['method'] );
396+
397+
if ( 'api_key' === $auth['method'] ) {
398+
$auth_out['settingName'] = $auth['setting_name'] ?? '';
399+
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
400+
}
401+
402+
$connector_out = array(
403+
'name' => $connector_data['name'],
404+
'description' => $connector_data['description'],
405+
'type' => $connector_data['type'],
406+
'authentication' => $auth_out,
407+
);
408+
409+
if ( ! empty( $connector_data['plugin'] ) ) {
410+
$connector_out['plugin'] = $connector_data['plugin'];
411+
}
412+
413+
$connectors[ $connector_id ] = $connector_out;
414+
}
415+
$data['connectors'] = $connectors;
416+
return $data;
417+
}
418+
add_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );

tests/phpunit/includes/functions.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,18 @@ function _unhook_font_registration() {
375375
}
376376
tests_add_filter( 'init', '_unhook_font_registration', 1000 );
377377

378+
/**
379+
* After the init action has been run once, trying to re-register connector settings can cause
380+
* duplicate registrations. To avoid this, unhook the connector registration functions.
381+
*
382+
* @since 7.0.0
383+
*/
384+
function _unhook_connector_registration() {
385+
remove_action( 'init', '_wp_register_default_connector_settings', 20 );
386+
remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
387+
}
388+
tests_add_filter( 'init', '_unhook_connector_registration', 1000 );
389+
378390
/**
379391
* Before the abilities API categories init action runs, unhook the core ability
380392
* categories registration function to prevent core categories from being registered

tests/phpunit/includes/wp-ai-client-mock-provider-trait.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,17 @@ private static function register_mock_connectors_provider(): void {
169169
private static function set_mock_provider_configured( bool $is_configured ): void {
170170
Mock_Connectors_Test_Provider_Availability::$is_configured = $is_configured;
171171
}
172+
173+
/**
174+
* Unregisters the mock provider's connector setting.
175+
*
176+
* Reverses the side effect of _wp_register_default_connector_settings()
177+
* for the mock provider so that subsequent test classes start with a clean slate.
178+
* Must be called from tear_down_after_class() after running tests.
179+
*/
180+
private static function unregister_mock_connector_setting(): void {
181+
$setting_name = 'connectors_ai_mock_connectors_test_api_key';
182+
unregister_setting( 'connectors', $setting_name );
183+
remove_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
184+
}
172185
}

0 commit comments

Comments
 (0)