diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 88a1fdf323f52..ad8bb52ff9c71 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -9,6 +9,29 @@ use WordPress\AiClient\AiClient; +/** + * Returns whether AI features are supported in the current environment. + * + * @since 7.0.0 + * + * @return bool Whether AI features are supported. + */ +function wp_supports_ai(): bool { + // Constant check gives a hard short-circuit for environments that cannot be overridden with a filter, such as wp-config.php settings or hosting provider configurations. + if ( defined( 'WP_AI_SUPPORT' ) && ! WP_AI_SUPPORT ) { + return false; + } + + /** + * Filters whether the current request should use AI. + * + * @since 7.0.0 + * + * @param bool $is_enabled Whether the current request should use AI. Default true. + */ + return (bool) apply_filters( 'wp_supports_ai', true ); +} + /** * Creates a new AI prompt builder using the default provider registry. * diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index 8c6e452b214ad..5f2531b341070 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -41,6 +41,8 @@ * * @since 7.0.0 * + * @phpstan-import-type Prompt from PromptBuilder + * * @method self with_text(string $text) Adds text to the current message. * @method self with_file($file, ?string $mimeType = null) Adds a file to the current message. * @method self with_function_response(FunctionResponse $functionResponse) Adds a function response to the current message. @@ -165,17 +167,22 @@ class WP_AI_Client_Prompt_Builder { * * @since 7.0.0 * - * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param string|MessagePart|Message|array|list|list|null $prompt Optional. Initial prompt content. - * A string for simple text prompts, - * a MessagePart or Message object for - * structured content, an array for a - * message array shape, or a list of - * parts or messages for multi-turn - * conversations. Default null. + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param Prompt $prompt Optional. Initial prompt content. + * A string for simple text prompts, + * a MessagePart or Message object for + * structured content, an array for a + * message array shape, or a list of + * parts or messages for multi-turn + * conversations. Default null. */ public function __construct( ProviderRegistry $registry, $prompt = null ) { try { + if ( ! wp_supports_ai() ) { + // The catch block will convert this to a WP_Error. + throw new \RuntimeException( __( 'AI features are not supported in the current environment.' ) ); + } + $this->builder = new PromptBuilder( $registry, $prompt ); } catch ( Exception $e ) { $this->builder = new PromptBuilder( $registry ); @@ -385,7 +392,8 @@ private static function is_generating_method( string $name ): bool { protected function get_builder_callable( string $name ): callable { $camel_case_name = $this->snake_to_camel_case( $name ); - if ( ! is_callable( array( $this->builder, $camel_case_name ) ) ) { + $method = array( $this->builder, $camel_case_name ); + if ( ! is_callable( $method ) ) { throw new BadMethodCallException( sprintf( /* translators: 1: Method name. 2: Class name. */ @@ -396,7 +404,7 @@ protected function get_builder_callable( string $name ): callable { ); } - return array( $this->builder, $camel_case_name ); + return $method; } /** diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 75a6b8ef0c993..fecec17308b03 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -144,6 +144,11 @@ public function register( string $id, array $args ): ?array { return null; } + if ( 'ai_provider' === $args['type'] & ! wp_supports_ai() ) { + // No need for a `doing_it_wrong` as AI support is disabled intentionally. + return null; + } + $connector = array( 'name' => $args['name'], 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 60f97839dabb6..d1d21b86b5563 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -120,6 +120,51 @@ function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { function _wp_connectors_init(): void { $registry = new WP_Connector_Registry(); WP_Connector_Registry::set_instance( $registry ); + + // Only register default AI providers if AI support is enabled. + if ( wp_supports_ai() ) { + _wp_connectors_register_default_ai_providers( $registry ); + } + + /** + * Fires when the connector registry is ready for plugins to register connectors. + * + * Default connectors have already been registered at this point and cannot be + * unhooked. Use `$registry->register()` within this action to add new connectors. + * + * Example usage: + * + * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { + * $registry->register( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } ); + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $registry Connector registry instance. + */ + do_action( 'wp_connectors_init', $registry ); +} + +/** + * Registers connectors for the built-in AI providers. + * + * @since 7.0.0 + * @access private + * + * @param WP_Connector_Registry $registry The connector registry instance. + */ +function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $registry ): void { // Built-in connectors. $defaults = array( 'anthropic' => array( @@ -218,35 +263,6 @@ function _wp_connectors_init(): void { foreach ( $defaults as $id => $args ) { $registry->register( $id, $args ); } - - /** - * Fires when the connector registry is ready for plugins to register connectors. - * - * Default connectors have already been registered at this point and cannot be - * unhooked. Use `$registry->register()` within this action to add new connectors. - * - * Example usage: - * - * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { - * $registry->register( - * 'my_custom_ai', - * array( - * 'name' => __( 'My Custom AI', 'my-plugin' ), - * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), - * 'type' => 'ai_provider', - * 'authentication' => array( - * 'method' => 'api_key', - * 'credentials_url' => 'https://example.com/api-keys', - * ), - * ) - * ); - * } ); - * - * @since 7.0.0 - * - * @param WP_Connector_Registry $registry Connector registry instance. - */ - do_action( 'wp_connectors_init', $registry ); } /** diff --git a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php index 287cd49116b71..6fcfeca19de6b 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPrompt.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPrompt.php @@ -30,4 +30,25 @@ public function test_returns_independent_instances() { $this->assertNotSame( $builder1, $builder2 ); } + + /** + * Tests that returns a WP_AI_Client_Prompt_Builder instance even when AI is not supported, but that the builder contains an error. + */ + public function test_returns_error_builder_when_ai_not_supported(): void { + // Temporarily disable AI support for this test. + add_filter( 'wp_supports_ai', '__return_false' ); + $builder = wp_ai_client_prompt(); + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $builder ); + + // Check the $error prop is a real WP_Error with the expected message. + $reflection = new ReflectionClass( $builder ); + $error_prop = $reflection->getProperty( 'error' ); + if ( PHP_VERSION_ID < 80100 ) { + $error_prop->setAccessible( true ); + } + $error = $error_prop->getValue( $builder ); + + $this->assertInstanceOf( WP_Error::class, $error ); + $this->assertSame( 'AI features are not supported in the current environment.', $error->get_error_message() ); + } } diff --git a/tests/phpunit/tests/ai-client/wpSupportsAI.php b/tests/phpunit/tests/ai-client/wpSupportsAI.php new file mode 100644 index 0000000000000..83346f1082d93 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpSupportsAI.php @@ -0,0 +1,30 @@ +assertTrue( wp_supports_ai() ); + } + + /** + * Tests that the wp_supports_ai filter can disable/enable AI features. + */ + public function test_filter_can_disable_ai_features(): void { + add_filter( 'wp_supports_ai', '__return_false' ); + $this->assertFalse( wp_supports_ai() ); + + // Try a later filter to re-enable AI and confirm that it works. + add_filter( 'wp_supports_ai', '__return_true' ); + $this->assertTrue( wp_supports_ai() ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 2a7ce199fa777..4034acef41a6e 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -116,6 +116,17 @@ public function test_includes_registered_provider_from_registry() { $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); } + /** + * Tests connectors return an empty array when AI is not supported + */ + public function test_returns_empty_array_when_ai_not_supported(): void { + // Temporarily disable AI support for this test. + add_filter( 'wp_supports_ai', '__return_false' ); + + $settings = _wp_connectors_get_connector_settings(); + $this->assertSame( array(), $settings ); + } + /** * @ticket 64730 */