Changeset 61943
- Timestamp:
- 03/11/2026 04:10:06 PM (2 weeks ago)
- Location:
- trunk
- Files:
-
- 4 added
- 7 edited
-
src/wp-includes/class-wp-connector-registry.php (added)
-
src/wp-includes/connectors.php (modified) (7 diffs)
-
src/wp-includes/default-filters.php (modified) (1 diff)
-
src/wp-settings.php (modified) (1 diff)
-
tests/phpunit/includes/wp-ai-client-mock-provider-trait.php (modified) (1 diff)
-
tests/phpunit/tests/connectors/wpConnectorRegistry.php (added)
-
tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php (modified) (1 diff)
-
tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php (added)
-
tests/phpunit/tests/connectors/wpRegisterConnector.php (added)
-
tests/phpunit/tests/rest-api/rest-settings-controller.php (modified) (1 diff)
-
tests/qunit/fixtures/wp-api-generated.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/connectors.php
r61825 r61943 11 11 use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; 12 12 13 14 /** 15 * Masks an API key, showing only the last 4 characters. 16 * 17 * @since 7.0.0 18 * @access private 19 * 20 * @param string $key The API key to mask. 21 * @return string The masked key, e.g. "************fj39". 22 */ 23 function _wp_connectors_mask_api_key( string $key ): string { 24 if ( strlen( $key ) <= 4 ) { 25 return $key; 26 } 27 28 return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); 29 } 30 31 /** 32 * Checks whether an API key is valid for a given provider. 33 * 34 * @since 7.0.0 35 * @access private 36 * 37 * @param string $key The API key to check. 38 * @param string $provider_id The WP AI client provider ID. 39 * @return bool|null True if valid, false if invalid, null if unable to determine. 40 */ 41 function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { 42 try { 43 $registry = AiClient::defaultRegistry(); 44 45 if ( ! $registry->hasProvider( $provider_id ) ) { 46 _doing_it_wrong( 47 __FUNCTION__, 48 sprintf( 49 /* translators: %s: AI provider ID. */ 50 __( 'The provider "%s" is not registered in the AI client registry.' ), 51 $provider_id 52 ), 53 '7.0.0' 54 ); 55 return null; 56 } 57 58 $registry->setProviderRequestAuthentication( 59 $provider_id, 60 new ApiKeyRequestAuthentication( $key ) 61 ); 62 63 return $registry->isProviderConfigured( $provider_id ); 64 } catch ( Exception $e ) { 65 wp_trigger_error( __FUNCTION__, $e->getMessage() ); 13 /** 14 * Checks if a connector is registered. 15 * 16 * @since 7.0.0 17 * 18 * @see WP_Connector_Registry::is_registered() 19 * 20 * @param string $id The connector identifier. 21 * @return bool True if the connector is registered, false otherwise. 22 */ 23 function wp_is_connector_registered( string $id ): bool { 24 $registry = WP_Connector_Registry::get_instance(); 25 if ( null === $registry ) { 26 return false; 27 } 28 29 return $registry->is_registered( $id ); 30 } 31 32 /** 33 * Retrieves a registered connector. 34 * 35 * @since 7.0.0 36 * 37 * @see WP_Connector_Registry::get_registered() 38 * 39 * @param string $id The connector identifier. 40 * @return array|null The registered connector data, or null if not registered. 41 */ 42 function wp_get_connector( string $id ): ?array { 43 $registry = WP_Connector_Registry::get_instance(); 44 if ( null === $registry ) { 66 45 return null; 67 46 } 68 } 69 70 /** 71 * Retrieves the real (unmasked) value of a connector API key. 72 * 73 * Temporarily removes the masking filter, reads the option, then re-adds it. 74 * 75 * @since 7.0.0 76 * @access private 77 * 78 * @param string $option_name The option name for the API key. 79 * @param callable $mask_callback The mask filter function. 80 * @return string The real API key value. 81 */ 82 function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { 83 remove_filter( "option_{$option_name}", $mask_callback ); 84 $value = get_option( $option_name, '' ); 85 add_filter( "option_{$option_name}", $mask_callback ); 86 return (string) $value; 87 } 88 89 /** 90 * Gets the registered connector settings. 91 * 92 * @since 7.0.0 93 * @access private 94 * 95 * @return array { 96 * Connector settings keyed by connector ID. 97 * 98 * @type array ...$0 { 99 * Data for a single connector. 100 * 101 * @type string $name The connector's display name. 102 * @type string $description The connector's description. 103 * @type string $type The connector type. Currently, only 'ai_provider' is supported. 104 * @type array $plugin Optional. Plugin data for install/activate UI. 105 * @type string $slug The WordPress.org plugin slug. 106 * } 107 * @type array $authentication { 108 * Authentication configuration. When method is 'api_key', includes 109 * credentials_url and setting_name. When 'none', only method is present. 110 * 111 * @type string $method The authentication method: 'api_key' or 'none'. 112 * @type string|null $credentials_url Optional. URL where users can obtain API credentials. 113 * @type string $setting_name Optional. The setting name for the API key. 114 * } 115 * } 116 * } 117 */ 118 function _wp_connectors_get_connector_settings(): array { 119 $connectors = array( 47 48 return $registry->get_registered( $id ); 49 } 50 51 /** 52 * Retrieves all registered connectors. 53 * 54 * @since 7.0.0 55 * 56 * @see WP_Connector_Registry::get_all_registered() 57 * 58 * @return array[] An array of registered connectors keyed by connector ID. 59 */ 60 function wp_get_connectors(): array { 61 $registry = WP_Connector_Registry::get_instance(); 62 if ( null === $registry ) { 63 return array(); 64 } 65 66 return $registry->get_all_registered(); 67 } 68 69 /** 70 * Resolves an AI provider logo file path to a URL. 71 * 72 * Converts an absolute file path to a plugin URL. The path must reside within 73 * the plugins or must-use plugins directory. 74 * 75 * @since 7.0.0 76 * @access private 77 * 78 * @param string $path Absolute path to the logo file. 79 * @return string|null The URL to the logo file, or null if the path is invalid. 80 */ 81 function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { 82 if ( ! $path ) { 83 return null; 84 } 85 86 $path = wp_normalize_path( $path ); 87 88 if ( ! file_exists( $path ) ) { 89 return null; 90 } 91 92 $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); 93 if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { 94 return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); 95 } 96 97 $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); 98 if ( str_starts_with( $path, $plugin_dir . '/' ) ) { 99 return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); 100 } 101 102 _doing_it_wrong( 103 __FUNCTION__, 104 __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ), 105 '7.0.0' 106 ); 107 108 return null; 109 } 110 111 /** 112 * Initializes the connector registry with default connectors and fires the registration action. 113 * 114 * Creates the registry instance, registers built-in connectors (which cannot be unhooked), 115 * and then fires the `wp_connectors_init` action for plugins to register their own connectors. 116 * 117 * @since 7.0.0 118 * @access private 119 */ 120 function _wp_connectors_init(): void { 121 $registry = new WP_Connector_Registry(); 122 WP_Connector_Registry::set_instance( $registry ); 123 // Built-in connectors. 124 $defaults = array( 120 125 'anthropic' => array( 121 126 'name' => 'Anthropic', … … 156 161 ); 157 162 158 $registry = AiClient::defaultRegistry(); 159 160 foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { 161 $provider_class_name = $registry->getProviderClassName( $connector_id ); 163 // Merge AI Client registry data on top of defaults. 164 // Registry values (from provider plugins) take precedence over hardcoded fallbacks. 165 $ai_registry = AiClient::defaultRegistry(); 166 167 foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { 168 $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); 162 169 $provider_metadata = $provider_class_name::metadata(); 163 170 … … 177 184 $name = $provider_metadata->getName(); 178 185 $description = $provider_metadata->getDescription(); 179 180 if ( isset( $connectors[ $connector_id ] ) ) { 186 $logo_url = $provider_metadata->getLogoPath() 187 ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) 188 : null; 189 190 if ( isset( $defaults[ $connector_id ] ) ) { 181 191 // Override fields with non-empty registry values. 182 192 if ( $name ) { 183 $ connectors[ $connector_id ]['name'] = $name;193 $defaults[ $connector_id ]['name'] = $name; 184 194 } 185 195 if ( $description ) { 186 $connectors[ $connector_id ]['description'] = $description; 196 $defaults[ $connector_id ]['description'] = $description; 197 } 198 if ( $logo_url ) { 199 $defaults[ $connector_id ]['logo_url'] = $logo_url; 187 200 } 188 201 // Always update auth method; keep existing credentials_url as fallback. 189 $ connectors[ $connector_id ]['authentication']['method'] = $authentication['method'];202 $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; 190 203 if ( ! empty( $authentication['credentials_url'] ) ) { 191 $ connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];204 $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; 192 205 } 193 206 } else { 194 $ connectors[ $connector_id ] = array(207 $defaults[ $connector_id ] = array( 195 208 'name' => $name ? $name : ucwords( $connector_id ), 196 209 'description' => $description ? $description : '', 197 210 'type' => 'ai_provider', 198 211 'authentication' => $authentication, 212 'logo_url' => $logo_url, 199 213 ); 200 214 } 201 215 } 202 216 217 // Register all default connectors directly on the registry. 218 foreach ( $defaults as $id => $args ) { 219 $registry->register( $id, $args ); 220 } 221 222 /** 223 * Fires when the connector registry is ready for plugins to register connectors. 224 * 225 * Default connectors have already been registered at this point and cannot be 226 * unhooked. Use `$registry->register()` within this action to add new connectors. 227 * 228 * Example usage: 229 * 230 * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { 231 * $registry->register( 232 * 'my_custom_ai', 233 * array( 234 * 'name' => __( 'My Custom AI', 'my-plugin' ), 235 * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), 236 * 'type' => 'ai_provider', 237 * 'authentication' => array( 238 * 'method' => 'api_key', 239 * 'credentials_url' => 'https://example.com/api-keys', 240 * ), 241 * ) 242 * ); 243 * } ); 244 * 245 * @since 7.0.0 246 * 247 * @param WP_Connector_Registry $registry Connector registry instance. 248 */ 249 do_action( 'wp_connectors_init', $registry ); 250 } 251 252 /** 253 * Masks an API key, showing only the last 4 characters. 254 * 255 * @since 7.0.0 256 * @access private 257 * 258 * @param string $key The API key to mask. 259 * @return string The masked key, e.g. "************fj39". 260 */ 261 function _wp_connectors_mask_api_key( string $key ): string { 262 if ( strlen( $key ) <= 4 ) { 263 return $key; 264 } 265 266 return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); 267 } 268 269 /** 270 * Checks whether an API key is valid for a given provider. 271 * 272 * @since 7.0.0 273 * @access private 274 * 275 * @param string $key The API key to check. 276 * @param string $provider_id The WP AI client provider ID. 277 * @return bool|null True if valid, false if invalid, null if unable to determine. 278 */ 279 function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { 280 try { 281 $registry = AiClient::defaultRegistry(); 282 283 if ( ! $registry->hasProvider( $provider_id ) ) { 284 _doing_it_wrong( 285 __FUNCTION__, 286 sprintf( 287 /* translators: %s: AI provider ID. */ 288 __( 'The provider "%s" is not registered in the AI client registry.' ), 289 $provider_id 290 ), 291 '7.0.0' 292 ); 293 return null; 294 } 295 296 $registry->setProviderRequestAuthentication( 297 $provider_id, 298 new ApiKeyRequestAuthentication( $key ) 299 ); 300 301 return $registry->isProviderConfigured( $provider_id ); 302 } catch ( Exception $e ) { 303 wp_trigger_error( __FUNCTION__, $e->getMessage() ); 304 return null; 305 } 306 } 307 308 /** 309 * Retrieves the real (unmasked) value of a connector API key. 310 * 311 * Temporarily removes the masking filter, reads the option, then re-adds it. 312 * 313 * @since 7.0.0 314 * @access private 315 * 316 * @param string $option_name The option name for the API key. 317 * @param callable $mask_callback The mask filter function. 318 * @return string The real API key value. 319 */ 320 function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { 321 remove_filter( "option_{$option_name}", $mask_callback ); 322 $value = get_option( $option_name, '' ); 323 add_filter( "option_{$option_name}", $mask_callback ); 324 return (string) $value; 325 } 326 327 /** 328 * Gets the registered connector settings. 329 * 330 * @since 7.0.0 331 * @access private 332 * 333 * @return array { 334 * Connector settings keyed by connector ID. 335 * 336 * @type array ...$0 { 337 * Data for a single connector. 338 * 339 * @type string $name The connector's display name. 340 * @type string $description The connector's description. 341 * @type string $type The connector type. Currently, only 'ai_provider' is supported. 342 * @type array $plugin Optional. Plugin data for install/activate UI. 343 * @type string $slug The WordPress.org plugin slug. 344 * } 345 * @type array $authentication { 346 * Authentication configuration. When method is 'api_key', includes 347 * credentials_url and setting_name. When 'none', only method is present. 348 * 349 * @type string $method The authentication method: 'api_key' or 'none'. 350 * @type string|null $credentials_url Optional. URL where users can obtain API credentials. 351 * @type string $setting_name Optional. The setting name for the API key. 352 * } 353 * } 354 * } 355 */ 356 function _wp_connectors_get_connector_settings(): array { 357 $connectors = wp_get_connectors(); 203 358 ksort( $connectors ); 204 205 // Add setting_name for connectors that use API key authentication.206 foreach ( $connectors as $connector_id => $connector ) {207 if ( 'api_key' === $connector['authentication']['method'] ) {208 $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key";209 }210 }211 212 359 return $connectors; 213 360 } … … 283 430 */ 284 431 function _wp_register_default_connector_settings(): void { 432 $ai_registry = AiClient::defaultRegistry(); 433 285 434 foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { 286 435 $auth = $connector_data['authentication']; 287 436 if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { 437 continue; 438 } 439 440 // Skip registering the setting if the provider is not in the registry. 441 if ( ! $ai_registry->hasProvider( $connector_id ) ) { 288 442 continue; 289 443 } … … 331 485 function _wp_connectors_pass_default_keys_to_ai_client(): void { 332 486 try { 333 $ registry = AiClient::defaultRegistry();487 $ai_registry = AiClient::defaultRegistry(); 334 488 foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { 335 489 if ( 'ai_provider' !== $connector_data['type'] ) { … … 342 496 } 343 497 498 if ( ! $ai_registry->hasProvider( $connector_id ) ) { 499 continue; 500 } 501 344 502 $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); 345 if ( '' === $api_key || ! $registry->hasProvider( $connector_id )) {503 if ( '' === $api_key ) { 346 504 continue; 347 505 } 348 506 349 $ registry->setProviderRequestAuthentication(507 $ai_registry->setProviderRequestAuthentication( 350 508 $connector_id, 351 509 new ApiKeyRequestAuthentication( $api_key ) … … 353 511 } 354 512 } catch ( Exception $e ) { 355 wp_trigger_error( __FUNCTION__, $e->getMessage() );513 wp_trigger_error( __FUNCTION__, $e->getMessage() ); 356 514 } 357 515 } -
trunk/src/wp-includes/default-filters.php
r61866 r61943 539 539 add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); 540 540 add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); 541 542 // Connectors API. 543 add_action( 'init', '_wp_connectors_init' ); 541 544 542 545 // Sitemaps actions. -
trunk/src/wp-settings.php
r61749 r61943 295 295 require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; 296 296 require ABSPATH . WPINC . '/ai-client.php'; 297 require ABSPATH . WPINC . '/class-wp-connector-registry.php'; 297 298 require ABSPATH . WPINC . '/connectors.php'; 298 299 require ABSPATH . WPINC . '/class-wp-icons-registry.php'; -
trunk/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
r61824 r61943 156 156 */ 157 157 private static function register_mock_connectors_provider(): void { 158 $registry = AiClient::defaultRegistry(); 159 if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) { 160 $registry->registerProvider( Mock_Connectors_Test_Provider::class ); 158 $ai_registry = AiClient::defaultRegistry(); 159 if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) { 160 $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class ); 161 } 162 163 // Also register in the WP connector registry if not already present. 164 $connector_registry = WP_Connector_Registry::get_instance(); 165 if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) { 166 $connector_registry->register( 167 'mock_connectors_test', 168 array( 169 'name' => 'Mock Connectors Test', 170 'description' => '', 171 'type' => 'ai_provider', 172 'authentication' => array( 173 'method' => 'api_key', 174 'credentials_url' => null, 175 ), 176 ) 177 ); 161 178 } 162 179 } -
trunk/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php
r61824 r61943 116 116 $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); 117 117 } 118 119 /** 120 * @ticket 64730 121 */ 122 public function test_connectors_are_sorted_alphabetically() { 123 $connectors = _wp_connectors_get_connector_settings(); 124 $keys = array_keys( $connectors ); 125 $sorted = $keys; 126 sort( $sorted ); 127 128 $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' ); 129 } 118 130 } -
trunk/tests/phpunit/tests/rest-api/rest-settings-controller.php
r61833 r61943 121 121 'site_icon', // Registered in wp-includes/blocks/site-logo.php 122 122 'wp_enable_real_time_collaboration', 123 // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php.124 'connectors_ai_anthropic_api_key',125 'connectors_ai_google_api_key',126 'connectors_ai_openai_api_key',127 123 ); 128 124 -
trunk/tests/qunit/fixtures/wp-api-generated.js
r61833 r61943 11067 11067 ], 11068 11068 "args": { 11069 "connectors_ai_anthropic_api_key": {11070 "title": "Anthropic API Key",11071 "description": "API key for the Anthropic AI provider.",11072 "type": "string",11073 "required": false11074 },11075 "connectors_ai_google_api_key": {11076 "title": "Google API Key",11077 "description": "API key for the Google AI provider.",11078 "type": "string",11079 "required": false11080 },11081 "connectors_ai_openai_api_key": {11082 "title": "OpenAI API Key",11083 "description": "API key for the OpenAI AI provider.",11084 "type": "string",11085 "required": false11086 },11087 11069 "title": { 11088 11070 "title": "Title", … … 14763 14745 14764 14746 mockedApiResponse.settings = { 14765 "connectors_ai_anthropic_api_key": "",14766 "connectors_ai_google_api_key": "",14767 "connectors_ai_openai_api_key": "",14768 14747 "title": "Test Blog", 14769 14748 "description": "",
Note: See TracChangeset
for help on using the changeset viewer.