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.
Built from https://develop.svn.wordpress.org/trunk@62180


git-svn-id: http://core.svn.wordpress.org/trunk@61462 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
jorgefilipecosta
2026-03-30 14:26:47 +00:00
parent 6ddd314762
commit 0d1ae47cde
3 changed files with 143 additions and 58 deletions

View File

@@ -31,11 +31,13 @@
* name: non-empty-string,
* description: non-empty-string,
* logo_url?: non-empty-string,
* type: 'ai_provider',
* type: non-empty-string,
* authentication: array{
* method: 'api_key'|'none',
* credentials_url?: non-empty-string,
* setting_name?: non-empty-string
* setting_name?: non-empty-string,
* constant_name?: non-empty-string,
* env_var_name?: non-empty-string
* },
* plugin?: array{
* slug: non-empty-string
@@ -66,12 +68,12 @@ final class WP_Connector_Registry {
* Registers a new connector.
*
* Validates the provided arguments and stores the connector in the registry.
* For connectors with `api_key` authentication, a `setting_name` is automatically
* generated using the pattern `connectors_ai_{$id}_api_key`, with hyphens in the ID
* normalized to underscores (e.g., connector ID `openai` produces
* `connectors_ai_openai_api_key`, and `azure-openai` produces
* `connectors_ai_azure_openai_api_key`). This setting name is used for the Settings
* API registration and REST API exposure.
* For connectors with `api_key` authentication, a `setting_name` can be provided
* explicitly. If omitted, one is automatically generated using the pattern
* `connectors_{$type}_{$id}_api_key`, with hyphens in the type and ID normalized
* to underscores (e.g., connector type `spam_filtering` with ID `akismet` produces
* `connectors_spam_filtering_akismet_api_key`). This setting name is used for the
* Settings API registration and REST API exposure.
*
* Registering a connector with an ID that is already registered will trigger a
* `_doing_it_wrong()` notice and return `null`. To override an existing connector,
@@ -89,12 +91,20 @@ final class WP_Connector_Registry {
* @type string $name Required. The connector's display name.
* @type string $description Optional. The connector's description. Default empty string.
* @type string $logo_url Optional. URL to the connector's logo image.
* @type string $type Required. The connector type. Currently, only 'ai_provider' is supported.
* @type string $type Required. The connector type, e.g. 'ai_provider'.
* @type array $authentication {
* Required. Authentication configuration.
*
* @type string $method Required. The authentication method: 'api_key' or 'none'.
* @type string $credentials_url Optional. URL where users can obtain API credentials.
* @type string $setting_name Optional. The setting name for the API key.
* When omitted, auto-generated as
* `connectors_{$type}_{$id}_api_key`.
* Must be a non-empty string when provided.
* @type string $constant_name Optional. PHP constant name for the API key
* (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided.
* @type string $env_var_name Optional. Environment variable name for the API key
* (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided.
* }
* @type array $plugin {
* Optional. Plugin data for install/activate UI.
@@ -192,10 +202,43 @@ final class WP_Connector_Registry {
if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) {
$connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'];
}
if ( ! empty( $args['authentication']['setting_name'] ) && is_string( $args['authentication']['setting_name'] ) ) {
if ( isset( $args['authentication']['setting_name'] ) ) {
if ( ! is_string( $args['authentication']['setting_name'] ) || '' === $args['authentication']['setting_name'] ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Connector ID. */
sprintf( __( 'Connector "%s" authentication setting_name must be a non-empty string.' ), esc_html( $id ) ),
'7.0.0'
);
return null;
}
$connector['authentication']['setting_name'] = $args['authentication']['setting_name'];
} else {
$connector['authentication']['setting_name'] = 'connectors_ai_' . str_replace( '-', '_', $id ) . '_api_key';
$connector['authentication']['setting_name'] = str_replace( '-', '_', "connectors_{$connector['type']}_{$id}_api_key" );
}
if ( isset( $args['authentication']['constant_name'] ) ) {
if ( ! is_string( $args['authentication']['constant_name'] ) || '' === $args['authentication']['constant_name'] ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Connector ID. */
sprintf( __( 'Connector "%s" authentication constant_name must be a non-empty string.' ), esc_html( $id ) ),
'7.0.0'
);
return null;
}
$connector['authentication']['constant_name'] = $args['authentication']['constant_name'];
}
if ( isset( $args['authentication']['env_var_name'] ) ) {
if ( ! is_string( $args['authentication']['env_var_name'] ) || '' === $args['authentication']['env_var_name'] ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Connector ID. */
sprintf( __( 'Connector "%s" authentication env_var_name must be a non-empty string.' ), esc_html( $id ) ),
'7.0.0'
);
return null;
}
$connector['authentication']['env_var_name'] = $args['authentication']['env_var_name'];
}
}

View File

@@ -43,14 +43,17 @@ function wp_is_connector_registered( string $id ): bool {
* @type string $name The connector's display name.
* @type string $description The connector's description.
* @type string $logo_url Optional. URL to the connector's logo image.
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
* @type string $type The connector type, e.g. 'ai_provider'.
* @type array $authentication {
* Authentication configuration. When method is 'api_key', includes
* credentials_url and setting_name. When 'none', only method is present.
* credentials_url, setting_name, and optionally constant_name and
* env_var_name. When 'none', only method is present.
*
* @type string $method The authentication method: 'api_key' or 'none'.
* @type string $credentials_url Optional. URL where users can obtain API credentials.
* @type string $setting_name Optional. The setting name for the API key.
* @type string $constant_name Optional. PHP constant name for the API key.
* @type string $env_var_name Optional. Environment variable name for the API key.
* }
* @type array $plugin {
* Optional. Plugin data for install/activate UI.
@@ -62,11 +65,13 @@ function wp_is_connector_registered( string $id ): bool {
* name: non-empty-string,
* description: non-empty-string,
* logo_url?: non-empty-string,
* type: 'ai_provider',
* type: non-empty-string,
* authentication: array{
* method: 'api_key'|'none',
* credentials_url?: non-empty-string,
* setting_name?: non-empty-string
* setting_name?: non-empty-string,
* constant_name?: non-empty-string,
* env_var_name?: non-empty-string
* },
* plugin?: array{
* slug: non-empty-string
@@ -98,14 +103,17 @@ function wp_get_connector( string $id ): ?array {
* @type string $name The connector's display name.
* @type string $description The connector's description.
* @type string $logo_url Optional. URL to the connector's logo image.
* @type string $type The connector type. Currently, only 'ai_provider' is supported.
* @type string $type The connector type, e.g. 'ai_provider'.
* @type array $authentication {
* Authentication configuration. When method is 'api_key', includes
* credentials_url and setting_name. When 'none', only method is present.
* credentials_url, setting_name, and optionally constant_name and
* env_var_name. When 'none', only method is present.
*
* @type string $method The authentication method: 'api_key' or 'none'.
* @type string $credentials_url Optional. URL where users can obtain API credentials.
* @type string $setting_name Optional. The setting name for the API key.
* @type string $constant_name Optional. PHP constant name for the API key.
* @type string $env_var_name Optional. Environment variable name for the API key.
* }
* @type array $plugin {
* Optional. Plugin data for install/activate UI.
@@ -118,11 +126,13 @@ function wp_get_connector( string $id ): ?array {
* name: non-empty-string,
* description: non-empty-string,
* logo_url?: non-empty-string,
* type: 'ai_provider',
* type: non-empty-string,
* authentication: array{
* method: 'api_key'|'none',
* credentials_url?: non-empty-string,
* setting_name?: non-empty-string
* setting_name?: non-empty-string,
* constant_name?: non-empty-string,
* env_var_name?: non-empty-string
* },
* plugin?: array{
* slug: non-empty-string
@@ -216,10 +226,10 @@ function _wp_connectors_init(): void {
* Example — overriding metadata on an auto-discovered connector:
*
* add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
* if ( $registry->is_registered( 'openai' ) ) {
* $connector = $registry->unregister( 'openai' );
* $connector['description'] = __( 'Custom description for OpenAI.', 'my-plugin' );
* $registry->register( 'openai', $connector );
* if ( $registry->is_registered( 'anthropic' ) ) {
* $connector = $registry->unregister( 'anthropic' );
* $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' );
* $registry->register( 'anthropic', $connector );
* }
* } );
*
@@ -335,6 +345,26 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re
// Register all default connectors directly on the registry.
foreach ( $defaults as $id => $args ) {
if ( 'api_key' === $args['authentication']['method'] ) {
$sanitized_id = str_replace( '-', '_', $id );
if ( ! isset( $args['authentication']['setting_name'] ) ) {
$args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
}
// All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) {
$constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
if ( ! isset( $args['authentication']['constant_name'] ) ) {
$args['authentication']['constant_name'] = $constant_case_key;
}
if ( ! isset( $args['authentication']['env_var_name'] ) ) {
$args['authentication']['env_var_name'] = $constant_case_key;
}
}
}
$registry->register( $id, $args );
}
}
@@ -357,35 +387,32 @@ function _wp_connectors_mask_api_key( string $key ): string {
}
/**
* Determines the source of an API key for a given provider.
* Determines the source of an API key for a given connector.
*
* Checks in order: environment variable, PHP constant, database.
* Uses the same naming convention as the WP AI Client ProviderRegistry.
* Environment variable and constant are only checked when their
* respective names are provided.
*
* @since 7.0.0
* @access private
*
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
* @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
* @param string $setting_name The option name for the API key (e.g., 'connectors_spam_filtering_akismet_api_key').
* @param string $env_var_name Optional. Environment variable name to check (e.g., 'AKISMET_API_KEY').
* @param string $constant_name Optional. PHP constant name to check (e.g., 'AKISMET_API_KEY').
* @return string The key source: 'env', 'constant', 'database', or 'none'.
*/
function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string {
// Convert provider ID to CONSTANT_CASE for env var name.
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
$constant_case_id = strtoupper(
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
);
$env_var_name = "{$constant_case_id}_API_KEY";
function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
// Check environment variable first.
$env_value = getenv( $env_var_name );
if ( false !== $env_value && '' !== $env_value ) {
return 'env';
if ( '' !== $env_var_name ) {
$env_value = getenv( $env_var_name );
if ( false !== $env_value && '' !== $env_value ) {
return 'env';
}
}
// Check PHP constant.
if ( defined( $env_var_name ) ) {
$const_value = constant( $env_var_name );
if ( '' !== $constant_name && defined( $constant_name ) ) {
$const_value = constant( $constant_name );
if ( is_string( $const_value ) && '' !== $const_value ) {
return 'constant';
}
@@ -470,7 +497,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}
@@ -481,8 +508,9 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
$value = $data[ $setting_name ];
// On update, validate the key before masking.
if ( $is_update && is_string( $value ) && '' !== $value ) {
// On update, validate AI provider keys before masking.
// Non-AI connectors accept keys as-is; the service plugin handles its own validation.
if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
update_option( $setting_name, '' );
$data[ $setting_name ] = '';
@@ -508,16 +536,22 @@ add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3
* @access private
*/
function _wp_register_default_connector_settings(): void {
$ai_registry = AiClient::defaultRegistry();
$ai_registry = AiClient::defaultRegistry();
$registered_settings = get_registered_settings();
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}
// Skip registering the setting if the provider is not in the registry.
if ( ! $ai_registry->hasProvider( $connector_id ) ) {
// Skip if the setting is already registered (e.g. by an owning plugin).
if ( isset( $registered_settings[ $auth['setting_name'] ] ) ) {
continue;
}
// For AI providers, skip if the provider is not in the AI Client registry.
if ( 'ai_provider' === $connector_data['type'] && ! $ai_registry->hasProvider( $connector_id ) ) {
continue;
}
@@ -527,13 +561,13 @@ function _wp_register_default_connector_settings(): void {
array(
'type' => 'string',
'label' => sprintf(
/* translators: %s: AI provider name. */
/* translators: %s: Connector name. */
__( '%s API Key' ),
$connector_data['name']
),
'description' => sprintf(
/* translators: %s: AI provider name. */
__( 'API key for the %s AI provider.' ),
/* translators: %s: Connector name. */
__( 'API key for the %s connector.' ),
$connector_data['name']
),
'default' => '',
@@ -569,7 +603,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
}
// Skip if the key is already provided via env var or constant.
$key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] );
$key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
if ( 'env' === $key_source || 'constant' === $key_source ) {
continue;
}
@@ -620,11 +654,17 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
if ( 'api_key' === $auth['method'] ) {
$auth_out['settingName'] = $auth['setting_name'] ?? '';
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
$auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' );
try {
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$auth_out['isConnected'] = false;
$key_source = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
$auth_out['keySource'] = $key_source;
if ( 'ai_provider' === $connector_data['type'] ) {
try {
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$auth_out['isConnected'] = false;
}
} else {
$auth_out['isConnected'] = 'none' !== $key_source;
}
}
@@ -645,7 +685,9 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
$connector_out['plugin'] = array(
'slug' => $plugin_slug,
'isInstalled' => $is_installed,
'pluginFile' => $is_installed
? ( str_ends_with( $plugin_file, '.php' ) ? substr( $plugin_file, 0, -4 ) : $plugin_file )
: null,
'isActivated' => $is_activated,
);
}

View File

@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '7.1-alpha-62179';
$wp_version = '7.1-alpha-62180';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.