Skip to content

Commit 7f7f2ea

Browse files
gzioloraftaar1191jorgefilipecostafelixarntz
authored
Connectors: Dynamically register providers from WP AI Client registry (#76014)
* Connectors: Dynamically register providers from WP AI Client registry Polyfills WordPress/wordpress-develop#11080 for the Gutenberg plugin: - Expand `_gutenberg_get_provider_settings()` to dynamically fetch registered providers from the AI Client registry, in addition to the three hardcoded featured providers (Gemini, OpenAI, Claude). - Restructure the return value to be keyed by provider ID with `name`, `description`, `credentials_url` at the top level and `settings` as a nested array. - Filter out providers whose authentication method is not `api_key`. - Update all consumer functions to use the new structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Expose provider settings to the script module Pass provider data (name, description, credentials URL, setting keys) to the `connectors-wp-admin` script module via the `script_module_data` filter, making it available as inline JSON for the frontend to consume. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Register connectors dynamically from server-provided data Replace hardcoded per-provider connector components with a single dynamic loop that reads provider data from the script module data JSON tag. Known providers retain their SVG logos via a client-side map; third-party providers from the registry render without a logo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Connectors: Use ucwords fallback for empty provider name When a third-party provider from the AI Client registry has no name, fall back to ucwords( $provider_id ) for a reasonable display label. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Add authentication_method to provider data structure Expose authentication_method ('api_key' or 'none') in provider settings instead of silently filtering out non-API-key providers. This makes the public-facing interfaces extensible for future authentication methods while still only implementing api_key support for now. - Include all registered providers regardless of auth method - Conditionally generate settings sub-array only for api_key providers - Expose authenticationMethod in script module data for the frontend - Skip non-api_key providers in the frontend registration loop - Rename ProviderConnector to ApiKeyProviderConnector for clarity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Add type field to distinguish AI providers from other connectors Add a 'type' field ('ai_provider') to the provider data structure so credentials are only passed to the WP AI Client for AI providers. The frontend also filters by type, only rendering connectors for AI providers. This separates AI providers from future non-AI connectors and makes the intent of the API explicit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Group authentication fields into sub-object and eliminate settings duplication Restructure provider data so credentials_url, setting_name, and method live together in an authentication sub-object rather than as flat top-level fields. Remove the redundant settings array from _gutenberg_get_provider_settings() and move register_setting logic (label, description, sanitize) into the consumer function. Update all PHP consumers to read from authentication directly. On the frontend, change ProviderAuthentication to a discriminated union type for type-safe access after narrowing on method. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Rename _gutenberg_get_provider_settings to _gutenberg_get_connector_settings Use the domain term "connector" consistently with the rest of the codebase (settings group, option names, script module filter). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Clarify authentication docblock for none method Document that credentials_url and setting_name are only present when method is 'api_key' and absent when method is 'none'. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Use URL hostname for help label instead of regex stripping Replace manual regex URL stripping with new URL().hostname for a cleaner, more robust extraction of the domain name used as the help link label. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Use type-based namespace for connector names Derive the connector name namespace from data.type instead of hardcoding 'core/'. Sanitize both parts to only allow letters, numbers, and hyphens. This produces names like 'ai-provider/google'. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Rename ApiKeyProviderConnector and derive helpLabel internally Rename ApiKeyProviderConnector to ApiKeyConnector and move helpLabel derivation from the registration loop into the component itself, since it already receives helpUrl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Update lib/experimental/connectors/default-connectors.php Co-authored-by: Felix Arntz <flixos90@gmail.com> * Connectors: Rename _gutenberg_is_api_key_valid to _gutenberg_is_ai_api_key_valid The function is AI-provider-specific, so the name should reflect that. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Move sanitize helper outside the loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Pass plugin data from server instead of deriving slug client-side Hardcode plugin slugs for the three featured AI providers in PHP within a `plugin` sub-object and pass them to the client via script module data. When no plugin data is provided (e.g. dynamically registered providers), the install/activate UI is skipped and the connector assumes the plugin is already active. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add backport changelog entry for Core PR #11080 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Connectors: Add remove_filter for Core script module data function Ensures the Gutenberg version overrides the equivalent Core function (_wp_connectors_get_connector_script_module_data), consistent with the pattern used by the other connector functions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Felix Arntz <flixos90@gmail.com> Co-authored-by: gziolo <gziolo@git.wordpress.org> Co-authored-by: raftaar1191 <raftaar1191@git.wordpress.org> Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org> Co-authored-by: felixarntz <flixos90@git.wordpress.org>
1 parent f167589 commit 7f7f2ea

File tree

4 files changed

+296
-119
lines changed

4 files changed

+296
-119
lines changed

backport-changelog/7.0/11080.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
https://github.com/WordPress/wordpress-develop/pull/11080
2+
3+
* https://github.com/WordPress/gutenberg/pull/76014

lib/experimental/connectors/default-connectors.php

Lines changed: 197 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function _gutenberg_mask_api_key( string $key ): string {
3030
* @param string $provider_id The WP AI client provider ID.
3131
* @return bool|null True if valid, false if invalid, null if unable to determine.
3232
*/
33-
function _gutenberg_is_api_key_valid( string $key, string $provider_id ): ?bool {
33+
function _gutenberg_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
3434
try {
3535
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
3636

@@ -78,55 +78,126 @@ function _gutenberg_get_real_api_key( string $option_name, callable $mask_callba
7878
}
7979

8080
/**
81-
* Gets the registered connector provider settings.
81+
* Gets the registered connector settings.
8282
*
8383
* @access private
8484
*
85-
* @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name.
85+
* @return array {
86+
* Connector settings keyed by connector ID.
87+
*
88+
* @type array ...$0 {
89+
* Data for a single connector.
90+
*
91+
* @type string $name The connector's display name.
92+
* @type string $description The connector's description.
93+
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
94+
* @type array $plugin Optional. Plugin data for install/activate UI.
95+
* @type string $slug The WordPress.org plugin slug.
96+
* }
97+
* @type array $authentication {
98+
* Authentication configuration. When method is 'api_key', includes
99+
* credentials_url and setting_name. When 'none', only method is present.
100+
*
101+
* @type string $method The authentication method: 'api_key' or 'none'.
102+
* @type string|null $credentials_url Optional. URL where users can obtain API credentials.
103+
* @type string $setting_name Optional. The setting name for the API key.
104+
* }
105+
* }
106+
* }
86107
*/
87-
function _gutenberg_get_provider_settings(): array {
88-
$providers = array(
108+
function _gutenberg_get_connector_settings(): array {
109+
$connectors = array(
89110
'google' => array(
90-
'name' => 'Google',
111+
'name' => 'Google',
112+
'description' => __( 'Text and image generation with Gemini and Imagen.', 'gutenberg' ),
113+
'type' => 'ai_provider',
114+
'plugin' => array(
115+
'slug' => 'ai-provider-for-google',
116+
),
117+
'authentication' => array(
118+
'method' => 'api_key',
119+
'credentials_url' => 'https://aistudio.google.com/api-keys',
120+
),
91121
),
92122
'openai' => array(
93-
'name' => 'OpenAI',
123+
'name' => 'OpenAI',
124+
'description' => __( 'Text and image generation with GPT and Dall-E.', 'gutenberg' ),
125+
'type' => 'ai_provider',
126+
'plugin' => array(
127+
'slug' => 'ai-provider-for-openai',
128+
),
129+
'authentication' => array(
130+
'method' => 'api_key',
131+
'credentials_url' => 'https://platform.openai.com/api-keys',
132+
),
94133
),
95134
'anthropic' => array(
96-
'name' => 'Anthropic',
135+
'name' => 'Anthropic',
136+
'description' => __( 'Text generation with Claude.', 'gutenberg' ),
137+
'type' => 'ai_provider',
138+
'plugin' => array(
139+
'slug' => 'ai-provider-for-anthropic',
140+
),
141+
'authentication' => array(
142+
'method' => 'api_key',
143+
'credentials_url' => 'https://platform.claude.com/settings/keys',
144+
),
97145
),
98146
);
99147

100-
$provider_settings = array();
101-
foreach ( $providers as $provider => $data ) {
102-
$setting_name = "connectors_ai_{$provider}_api_key";
148+
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
103149

104-
$provider_settings[ $setting_name ] = array(
105-
'provider' => $provider,
106-
'label' => sprintf(
107-
/* translators: %s: AI provider name. */
108-
__( '%s API Key', 'gutenberg' ),
109-
$data['name']
110-
),
111-
'description' => sprintf(
112-
/* translators: %s: AI provider name. */
113-
__( 'API key for the %s AI provider.', 'gutenberg' ),
114-
$data['name']
115-
),
116-
'mask' => '_gutenberg_mask_api_key',
117-
'sanitize' => static function ( string $value ) use ( $provider ): string {
118-
$value = sanitize_text_field( $value );
119-
if ( '' === $value ) {
120-
return $value;
121-
}
122-
123-
$valid = _gutenberg_is_api_key_valid( $value, $provider );
124-
return true === $valid ? $value : '';
125-
},
126-
);
150+
foreach ( $registry->getRegisteredProviderIds() as $connector_id ) {
151+
$provider_class = $registry->getProviderClassName( $connector_id );
152+
$metadata = $provider_class::metadata();
153+
154+
$auth_method = $metadata->getAuthenticationMethod();
155+
$is_api_key = null !== $auth_method && $auth_method->isApiKey();
156+
157+
if ( $is_api_key ) {
158+
$credentials_url = $metadata->getCredentialsUrl();
159+
$authentication = array(
160+
'method' => 'api_key',
161+
'credentials_url' => $credentials_url ? $credentials_url : null,
162+
);
163+
} else {
164+
$authentication = array( 'method' => 'none' );
165+
}
166+
167+
$name = $metadata->getName();
168+
$description = method_exists( $metadata, 'getDescription' ) ? $metadata->getDescription() : null;
169+
170+
if ( isset( $connectors[ $connector_id ] ) ) {
171+
// Override fields with non-empty registry values.
172+
if ( $name ) {
173+
$connectors[ $connector_id ]['name'] = $name;
174+
}
175+
if ( $description ) {
176+
$connectors[ $connector_id ]['description'] = $description;
177+
}
178+
// Always update auth method; keep existing credentials_url as fallback.
179+
$connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];
180+
if ( ! empty( $authentication['credentials_url'] ) ) {
181+
$connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
182+
}
183+
} else {
184+
$connectors[ $connector_id ] = array(
185+
'name' => $name ? $name : ucwords( $connector_id ),
186+
'description' => $description ? $description : '',
187+
'type' => 'ai_provider',
188+
'authentication' => $authentication,
189+
);
190+
}
127191
}
128192

129-
return $provider_settings;
193+
// Add setting_name for connectors that use API key authentication.
194+
foreach ( $connectors as $connector_id => $connector ) {
195+
if ( 'api_key' === $connector['authentication']['method'] ) {
196+
$connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";
197+
}
198+
}
199+
200+
return $connectors;
130201
}
131202

132203
/**
@@ -169,17 +240,23 @@ function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response,
169240
return $response;
170241
}
171242

172-
foreach ( _gutenberg_get_provider_settings() as $setting_name => $config ) {
243+
foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
244+
$auth = $connector_data['authentication'];
245+
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
246+
continue;
247+
}
248+
249+
$setting_name = $auth['setting_name'];
173250
if ( ! in_array( $setting_name, $requested, true ) ) {
174251
continue;
175252
}
176253

177-
$real_key = _gutenberg_get_real_api_key( $setting_name, $config['mask'] );
254+
$real_key = _gutenberg_get_real_api_key( $setting_name, '_gutenberg_mask_api_key' );
178255
if ( '' === $real_key ) {
179256
continue;
180257
}
181258

182-
if ( true !== _gutenberg_is_api_key_valid( $real_key, $config['provider'] ) ) {
259+
if ( true !== _gutenberg_is_ai_api_key_valid( $real_key, $connector_id ) ) {
183260
$data[ $setting_name ] = 'invalid_key';
184261
}
185262
}
@@ -200,20 +277,42 @@ function _gutenberg_register_default_connector_settings(): void {
200277
return;
201278
}
202279

203-
foreach ( _gutenberg_get_provider_settings() as $setting_name => $config ) {
280+
foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
281+
$auth = $connector_data['authentication'];
282+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
283+
continue;
284+
}
285+
286+
$setting_name = $auth['setting_name'];
204287
register_setting(
205288
'connectors',
206289
$setting_name,
207290
array(
208291
'type' => 'string',
209-
'label' => $config['label'],
210-
'description' => $config['description'],
292+
'label' => sprintf(
293+
/* translators: %s: AI provider name. */
294+
__( '%s API Key', 'gutenberg' ),
295+
$connector_data['name']
296+
),
297+
'description' => sprintf(
298+
/* translators: %s: AI provider name. */
299+
__( 'API key for the %s AI provider.', 'gutenberg' ),
300+
$connector_data['name']
301+
),
211302
'default' => '',
212303
'show_in_rest' => true,
213-
'sanitize_callback' => $config['sanitize'],
304+
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
305+
$value = sanitize_text_field( $value );
306+
if ( '' === $value ) {
307+
return $value;
308+
}
309+
310+
$valid = _gutenberg_is_ai_api_key_valid( $value, $connector_id );
311+
return true === $valid ? $value : '';
312+
},
214313
)
215314
);
216-
add_filter( "option_{$setting_name}", $config['mask'] );
315+
add_filter( "option_{$setting_name}", '_gutenberg_mask_api_key' );
217316
}
218317
}
219318
remove_action( 'init', '_wp_register_default_connector_settings' );
@@ -231,14 +330,23 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
231330

232331
try {
233332
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
234-
foreach ( _gutenberg_get_provider_settings() as $setting_name => $config ) {
235-
$api_key = _gutenberg_get_real_api_key( $setting_name, $config['mask'] );
236-
if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) {
333+
foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
334+
if ( 'ai_provider' !== $connector_data['type'] ) {
335+
continue;
336+
}
337+
338+
$auth = $connector_data['authentication'];
339+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
340+
continue;
341+
}
342+
343+
$api_key = _gutenberg_get_real_api_key( $auth['setting_name'], '_gutenberg_mask_api_key' );
344+
if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
237345
continue;
238346
}
239347

240348
$registry->setProviderRequestAuthentication(
241-
$config['provider'],
349+
$connector_id,
242350
new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key )
243351
);
244352
}
@@ -248,3 +356,45 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
248356
}
249357
remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' );
250358
add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client' );
359+
360+
/**
361+
* Exposes connector settings to the connectors-wp-admin script module.
362+
*
363+
* @access private
364+
*
365+
* @param array $data Existing script module data.
366+
* @return array Script module data with connectors added.
367+
*/
368+
function _gutenberg_get_connector_script_module_data( array $data ): array {
369+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
370+
return $data;
371+
}
372+
373+
$connectors = array();
374+
foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
375+
$auth = $connector_data['authentication'];
376+
$auth_out = array( 'method' => $auth['method'] );
377+
378+
if ( 'api_key' === $auth['method'] ) {
379+
$auth_out['settingName'] = $auth['setting_name'] ?? '';
380+
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
381+
}
382+
383+
$connector_out = array(
384+
'name' => $connector_data['name'],
385+
'description' => $connector_data['description'],
386+
'type' => $connector_data['type'],
387+
'authentication' => $auth_out,
388+
);
389+
390+
if ( ! empty( $connector_data['plugin'] ) ) {
391+
$connector_out['plugin'] = $connector_data['plugin'];
392+
}
393+
394+
$connectors[ $connector_id ] = $connector_out;
395+
}
396+
$data['connectors'] = $connectors;
397+
return $data;
398+
}
399+
remove_filter( 'script_module_data_connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
400+
add_filter( 'script_module_data_connectors-wp-admin', '_gutenberg_get_connector_script_module_data' );

0 commit comments

Comments
 (0)