Skip to content

Commit 5462407

Browse files
Connectors: Fix and generalize the API for custom connector types.
Validate `setting_name`, `constant_name`, and `env_var_name` in connector registration — reject invalid values with `_doing_it_wrong()` instead of silently falling back. Change the auto-generated `setting_name` pattern from `connectors_ai_{$id}_api_key` to `connectors_{$type}_{$id}_api_key` so it works for any connector type. Built-in AI providers infer their names using the existing `connectors_ai_{$id}_api_key` convention, preserving backward compatibility. Add `constant_name` and `env_var_name` as optional authentication fields, allowing connectors to declare explicit PHP constant and environment variable names for API key lookup. AI providers auto-generate these using the `{CONSTANT_CASE_ID}_API_KEY` convention. Refactor `_wp_connectors_get_api_key_source()` to accept explicit `env_var_name` and `constant_name` parameters instead of deriving them from the provider ID. Environment variable and constant checks are skipped when not provided. Generalize REST dispatch, settings registration, and script module data to work with all connector types, not just `ai_provider`. Settings registration skips already-registered settings. Non-AI connectors determine `isConnected` based on key source. Replace `isInstalled` with `pluginFile` in script module data output to fix plugin entity ID resolution on the frontend. Update PHPDoc to reflect current behavior — widen `type` from literal `'ai_provider'` to `non-empty-string`, document new authentication fields, and use Anthropic examples throughout. Props gziolo, jorgefilipecosta. Fixes #64957. git-svn-id: https://develop.svn.wordpress.org/trunk@62180 602fd350-edb4-49c9-b593-d223f7449a82
1 parent a906b96 commit 5462407

5 files changed

Lines changed: 451 additions & 72 deletions

File tree

src/wp-includes/class-wp-connector-registry.php

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@
3131
* name: non-empty-string,
3232
* description: non-empty-string,
3333
* logo_url?: non-empty-string,
34-
* type: 'ai_provider',
34+
* type: non-empty-string,
3535
* authentication: array{
3636
* method: 'api_key'|'none',
3737
* credentials_url?: non-empty-string,
38-
* setting_name?: non-empty-string
38+
* setting_name?: non-empty-string,
39+
* constant_name?: non-empty-string,
40+
* env_var_name?: non-empty-string
3941
* },
4042
* plugin?: array{
4143
* slug: non-empty-string
@@ -66,12 +68,12 @@ final class WP_Connector_Registry {
6668
* Registers a new connector.
6769
*
6870
* Validates the provided arguments and stores the connector in the registry.
69-
* For connectors with `api_key` authentication, a `setting_name` is automatically
70-
* generated using the pattern `connectors_ai_{$id}_api_key`, with hyphens in the ID
71-
* normalized to underscores (e.g., connector ID `openai` produces
72-
* `connectors_ai_openai_api_key`, and `azure-openai` produces
73-
* `connectors_ai_azure_openai_api_key`). This setting name is used for the Settings
74-
* API registration and REST API exposure.
71+
* For connectors with `api_key` authentication, a `setting_name` can be provided
72+
* explicitly. If omitted, one is automatically generated using the pattern
73+
* `connectors_{$type}_{$id}_api_key`, with hyphens in the type and ID normalized
74+
* to underscores (e.g., connector type `spam_filtering` with ID `akismet` produces
75+
* `connectors_spam_filtering_akismet_api_key`). This setting name is used for the
76+
* Settings API registration and REST API exposure.
7577
*
7678
* Registering a connector with an ID that is already registered will trigger a
7779
* `_doing_it_wrong()` notice and return `null`. To override an existing connector,
@@ -89,12 +91,20 @@ final class WP_Connector_Registry {
8991
* @type string $name Required. The connector's display name.
9092
* @type string $description Optional. The connector's description. Default empty string.
9193
* @type string $logo_url Optional. URL to the connector's logo image.
92-
* @type string $type Required. The connector type. Currently, only 'ai_provider' is supported.
94+
* @type string $type Required. The connector type, e.g. 'ai_provider'.
9395
* @type array $authentication {
9496
* Required. Authentication configuration.
9597
*
9698
* @type string $method Required. The authentication method: 'api_key' or 'none'.
9799
* @type string $credentials_url Optional. URL where users can obtain API credentials.
100+
* @type string $setting_name Optional. The setting name for the API key.
101+
* When omitted, auto-generated as
102+
* `connectors_{$type}_{$id}_api_key`.
103+
* Must be a non-empty string when provided.
104+
* @type string $constant_name Optional. PHP constant name for the API key
105+
* (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided.
106+
* @type string $env_var_name Optional. Environment variable name for the API key
107+
* (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided.
98108
* }
99109
* @type array $plugin {
100110
* Optional. Plugin data for install/activate UI.
@@ -192,10 +202,43 @@ public function register( string $id, array $args ): ?array {
192202
if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) {
193203
$connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'];
194204
}
195-
if ( ! empty( $args['authentication']['setting_name'] ) && is_string( $args['authentication']['setting_name'] ) ) {
205+
if ( isset( $args['authentication']['setting_name'] ) ) {
206+
if ( ! is_string( $args['authentication']['setting_name'] ) || '' === $args['authentication']['setting_name'] ) {
207+
_doing_it_wrong(
208+
__METHOD__,
209+
/* translators: %s: Connector ID. */
210+
sprintf( __( 'Connector "%s" authentication setting_name must be a non-empty string.' ), esc_html( $id ) ),
211+
'7.0.0'
212+
);
213+
return null;
214+
}
196215
$connector['authentication']['setting_name'] = $args['authentication']['setting_name'];
197216
} else {
198-
$connector['authentication']['setting_name'] = 'connectors_ai_' . str_replace( '-', '_', $id ) . '_api_key';
217+
$connector['authentication']['setting_name'] = str_replace( '-', '_', "connectors_{$connector['type']}_{$id}_api_key" );
218+
}
219+
if ( isset( $args['authentication']['constant_name'] ) ) {
220+
if ( ! is_string( $args['authentication']['constant_name'] ) || '' === $args['authentication']['constant_name'] ) {
221+
_doing_it_wrong(
222+
__METHOD__,
223+
/* translators: %s: Connector ID. */
224+
sprintf( __( 'Connector "%s" authentication constant_name must be a non-empty string.' ), esc_html( $id ) ),
225+
'7.0.0'
226+
);
227+
return null;
228+
}
229+
$connector['authentication']['constant_name'] = $args['authentication']['constant_name'];
230+
}
231+
if ( isset( $args['authentication']['env_var_name'] ) ) {
232+
if ( ! is_string( $args['authentication']['env_var_name'] ) || '' === $args['authentication']['env_var_name'] ) {
233+
_doing_it_wrong(
234+
__METHOD__,
235+
/* translators: %s: Connector ID. */
236+
sprintf( __( 'Connector "%s" authentication env_var_name must be a non-empty string.' ), esc_html( $id ) ),
237+
'7.0.0'
238+
);
239+
return null;
240+
}
241+
$connector['authentication']['env_var_name'] = $args['authentication']['env_var_name'];
199242
}
200243
}
201244

src/wp-includes/connectors.php

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,17 @@ function wp_is_connector_registered( string $id ): bool {
4343
* @type string $name The connector's display name.
4444
* @type string $description The connector's description.
4545
* @type string $logo_url Optional. URL to the connector's logo image.
46-
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
46+
* @type string $type The connector type, e.g. 'ai_provider'.
4747
* @type array $authentication {
4848
* Authentication configuration. When method is 'api_key', includes
49-
* credentials_url and setting_name. When 'none', only method is present.
49+
* credentials_url, setting_name, and optionally constant_name and
50+
* env_var_name. When 'none', only method is present.
5051
*
5152
* @type string $method The authentication method: 'api_key' or 'none'.
5253
* @type string $credentials_url Optional. URL where users can obtain API credentials.
5354
* @type string $setting_name Optional. The setting name for the API key.
55+
* @type string $constant_name Optional. PHP constant name for the API key.
56+
* @type string $env_var_name Optional. Environment variable name for the API key.
5457
* }
5558
* @type array $plugin {
5659
* Optional. Plugin data for install/activate UI.
@@ -62,11 +65,13 @@ function wp_is_connector_registered( string $id ): bool {
6265
* name: non-empty-string,
6366
* description: non-empty-string,
6467
* logo_url?: non-empty-string,
65-
* type: 'ai_provider',
68+
* type: non-empty-string,
6669
* authentication: array{
6770
* method: 'api_key'|'none',
6871
* credentials_url?: non-empty-string,
69-
* setting_name?: non-empty-string
72+
* setting_name?: non-empty-string,
73+
* constant_name?: non-empty-string,
74+
* env_var_name?: non-empty-string
7075
* },
7176
* plugin?: array{
7277
* slug: non-empty-string
@@ -98,14 +103,17 @@ function wp_get_connector( string $id ): ?array {
98103
* @type string $name The connector's display name.
99104
* @type string $description The connector's description.
100105
* @type string $logo_url Optional. URL to the connector's logo image.
101-
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
106+
* @type string $type The connector type, e.g. 'ai_provider'.
102107
* @type array $authentication {
103108
* Authentication configuration. When method is 'api_key', includes
104-
* credentials_url and setting_name. When 'none', only method is present.
109+
* credentials_url, setting_name, and optionally constant_name and
110+
* env_var_name. When 'none', only method is present.
105111
*
106112
* @type string $method The authentication method: 'api_key' or 'none'.
107113
* @type string $credentials_url Optional. URL where users can obtain API credentials.
108114
* @type string $setting_name Optional. The setting name for the API key.
115+
* @type string $constant_name Optional. PHP constant name for the API key.
116+
* @type string $env_var_name Optional. Environment variable name for the API key.
109117
* }
110118
* @type array $plugin {
111119
* Optional. Plugin data for install/activate UI.
@@ -118,11 +126,13 @@ function wp_get_connector( string $id ): ?array {
118126
* name: non-empty-string,
119127
* description: non-empty-string,
120128
* logo_url?: non-empty-string,
121-
* type: 'ai_provider',
129+
* type: non-empty-string,
122130
* authentication: array{
123131
* method: 'api_key'|'none',
124132
* credentials_url?: non-empty-string,
125-
* setting_name?: non-empty-string
133+
* setting_name?: non-empty-string,
134+
* constant_name?: non-empty-string,
135+
* env_var_name?: non-empty-string
126136
* },
127137
* plugin?: array{
128138
* slug: non-empty-string
@@ -216,10 +226,10 @@ function _wp_connectors_init(): void {
216226
* Example — overriding metadata on an auto-discovered connector:
217227
*
218228
* add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
219-
* if ( $registry->is_registered( 'openai' ) ) {
220-
* $connector = $registry->unregister( 'openai' );
221-
* $connector['description'] = __( 'Custom description for OpenAI.', 'my-plugin' );
222-
* $registry->register( 'openai', $connector );
229+
* if ( $registry->is_registered( 'anthropic' ) ) {
230+
* $connector = $registry->unregister( 'anthropic' );
231+
* $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' );
232+
* $registry->register( 'anthropic', $connector );
223233
* }
224234
* } );
225235
*
@@ -335,6 +345,26 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re
335345

336346
// Register all default connectors directly on the registry.
337347
foreach ( $defaults as $id => $args ) {
348+
if ( 'api_key' === $args['authentication']['method'] ) {
349+
$sanitized_id = str_replace( '-', '_', $id );
350+
351+
if ( ! isset( $args['authentication']['setting_name'] ) ) {
352+
$args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
353+
}
354+
355+
// All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
356+
if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) {
357+
$constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
358+
359+
if ( ! isset( $args['authentication']['constant_name'] ) ) {
360+
$args['authentication']['constant_name'] = $constant_case_key;
361+
}
362+
363+
if ( ! isset( $args['authentication']['env_var_name'] ) ) {
364+
$args['authentication']['env_var_name'] = $constant_case_key;
365+
}
366+
}
367+
}
338368
$registry->register( $id, $args );
339369
}
340370
}
@@ -357,35 +387,32 @@ function _wp_connectors_mask_api_key( string $key ): string {
357387
}
358388

359389
/**
360-
* Determines the source of an API key for a given provider.
390+
* Determines the source of an API key for a given connector.
361391
*
362392
* Checks in order: environment variable, PHP constant, database.
363-
* Uses the same naming convention as the WP AI Client ProviderRegistry.
393+
* Environment variable and constant are only checked when their
394+
* respective names are provided.
364395
*
365396
* @since 7.0.0
366397
* @access private
367398
*
368-
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
369-
* @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
399+
* @param string $setting_name The option name for the API key (e.g., 'connectors_spam_filtering_akismet_api_key').
400+
* @param string $env_var_name Optional. Environment variable name to check (e.g., 'AKISMET_API_KEY').
401+
* @param string $constant_name Optional. PHP constant name to check (e.g., 'AKISMET_API_KEY').
370402
* @return string The key source: 'env', 'constant', 'database', or 'none'.
371403
*/
372-
function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string {
373-
// Convert provider ID to CONSTANT_CASE for env var name.
374-
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
375-
$constant_case_id = strtoupper(
376-
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
377-
);
378-
$env_var_name = "{$constant_case_id}_API_KEY";
379-
404+
function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
380405
// Check environment variable first.
381-
$env_value = getenv( $env_var_name );
382-
if ( false !== $env_value && '' !== $env_value ) {
383-
return 'env';
406+
if ( '' !== $env_var_name ) {
407+
$env_value = getenv( $env_var_name );
408+
if ( false !== $env_value && '' !== $env_value ) {
409+
return 'env';
410+
}
384411
}
385412

386413
// Check PHP constant.
387-
if ( defined( $env_var_name ) ) {
388-
$const_value = constant( $env_var_name );
414+
if ( '' !== $constant_name && defined( $constant_name ) ) {
415+
$const_value = constant( $constant_name );
389416
if ( is_string( $const_value ) && '' !== $const_value ) {
390417
return 'constant';
391418
}
@@ -470,7 +497,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
470497

471498
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
472499
$auth = $connector_data['authentication'];
473-
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
500+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
474501
continue;
475502
}
476503

@@ -481,8 +508,9 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
481508

482509
$value = $data[ $setting_name ];
483510

484-
// On update, validate the key before masking.
485-
if ( $is_update && is_string( $value ) && '' !== $value ) {
511+
// On update, validate AI provider keys before masking.
512+
// Non-AI connectors accept keys as-is; the service plugin handles its own validation.
513+
if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
486514
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
487515
update_option( $setting_name, '' );
488516
$data[ $setting_name ] = '';
@@ -508,16 +536,22 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
508536
* @access private
509537
*/
510538
function _wp_register_default_connector_settings(): void {
511-
$ai_registry = AiClient::defaultRegistry();
539+
$ai_registry = AiClient::defaultRegistry();
540+
$registered_settings = get_registered_settings();
512541

513542
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
514543
$auth = $connector_data['authentication'];
515-
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
544+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
545+
continue;
546+
}
547+
548+
// Skip if the setting is already registered (e.g. by an owning plugin).
549+
if ( isset( $registered_settings[ $auth['setting_name'] ] ) ) {
516550
continue;
517551
}
518552

519-
// Skip registering the setting if the provider is not in the registry.
520-
if ( ! $ai_registry->hasProvider( $connector_id ) ) {
553+
// For AI providers, skip if the provider is not in the AI Client registry.
554+
if ( 'ai_provider' === $connector_data['type'] && ! $ai_registry->hasProvider( $connector_id ) ) {
521555
continue;
522556
}
523557

@@ -527,13 +561,13 @@ function _wp_register_default_connector_settings(): void {
527561
array(
528562
'type' => 'string',
529563
'label' => sprintf(
530-
/* translators: %s: AI provider name. */
564+
/* translators: %s: Connector name. */
531565
__( '%s API Key' ),
532566
$connector_data['name']
533567
),
534568
'description' => sprintf(
535-
/* translators: %s: AI provider name. */
536-
__( 'API key for the %s AI provider.' ),
569+
/* translators: %s: Connector name. */
570+
__( 'API key for the %s connector.' ),
537571
$connector_data['name']
538572
),
539573
'default' => '',
@@ -569,7 +603,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
569603
}
570604

571605
// Skip if the key is already provided via env var or constant.
572-
$key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] );
606+
$key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
573607
if ( 'env' === $key_source || 'constant' === $key_source ) {
574608
continue;
575609
}
@@ -620,11 +654,17 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
620654
if ( 'api_key' === $auth['method'] ) {
621655
$auth_out['settingName'] = $auth['setting_name'] ?? '';
622656
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
623-
$auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' );
624-
try {
625-
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
626-
} catch ( Exception $e ) {
627-
$auth_out['isConnected'] = false;
657+
$key_source = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
658+
$auth_out['keySource'] = $key_source;
659+
660+
if ( 'ai_provider' === $connector_data['type'] ) {
661+
try {
662+
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
663+
} catch ( Exception $e ) {
664+
$auth_out['isConnected'] = false;
665+
}
666+
} else {
667+
$auth_out['isConnected'] = 'none' !== $key_source;
628668
}
629669
}
630670

@@ -645,7 +685,9 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
645685

646686
$connector_out['plugin'] = array(
647687
'slug' => $plugin_slug,
648-
'isInstalled' => $is_installed,
688+
'pluginFile' => $is_installed
689+
? ( str_ends_with( $plugin_file, '.php' ) ? substr( $plugin_file, 0, -4 ) : $plugin_file )
690+
: null,
649691
'isActivated' => $is_activated,
650692
);
651693
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ private static function register_mock_connectors_provider(): void {
172172
'authentication' => array(
173173
'method' => 'api_key',
174174
'credentials_url' => null,
175+
'setting_name' => 'connectors_ai_mock_connectors_test_api_key',
175176
),
176177
)
177178
);

0 commit comments

Comments
 (0)