diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 88a1fdf323f52..5ca4801fb9f5d 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 ensures 3rd-party code cannot override the explicit preferences defined by the environment. + 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 28df53a2718d5..7c817872a0e20 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 @@ -46,6 +46,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. @@ -170,14 +172,14 @@ 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 { @@ -289,15 +291,20 @@ public function __call( string $name, array $arguments ) { // Check if the prompt should be prevented for is_supported* and generate_*/convert_text_to_speech* methods. if ( self::is_support_check_method( $name ) || self::is_generating_method( $name ) ) { - /** - * Filters whether to prevent the prompt from being executed. - * - * @since 7.0.0 - * - * @param bool $prevent Whether to prevent the prompt. Default false. - * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). - */ - $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); + // If AI is not supported, then there's no need to apply the filter as the prompt will be prevented anyway. + $is_ai_disabled = ! wp_supports_ai(); + $prevent = $is_ai_disabled; + if ( ! $prevent ) { + /** + * Filters whether to prevent the prompt from being executed. + * + * @since 7.0.0 + * + * @param bool $prevent Whether to prevent the prompt. Default false. + * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only). + */ + $prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this ); + } if ( $prevent ) { // For is_supported* methods, return false. @@ -305,10 +312,14 @@ public function __call( string $name, array $arguments ) { return false; } + $error_message = $is_ai_disabled + ? __( 'AI features are not supported in this environment.' ) + : __( 'Prompt execution was prevented by a filter.' ); + // For generate_* and convert_text_to_speech* methods, create a WP_Error. $this->error = new WP_Error( 'prompt_prevented', - __( 'Prompt execution was prevented by a filter.' ), + $error_message, array( 'status' => 503, ) @@ -423,7 +434,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. */ @@ -434,7 +446,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 4f4c95014ff76..ac80c9c1c902a 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -168,6 +168,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 dbf44a0a1ffd6..985a7b0720977 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -332,6 +332,53 @@ 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. + * + * Built-in connectors and any AI providers auto-discovered from the WP AI Client + * registry have already been registered at this point and cannot be unhooked. + * + * AI provider plugins that register with the WP AI Client do not need to use + * this action — their connectors are created automatically. This action is + * primarily for registering non-AI-provider connectors or overriding metadata + * on existing connectors. + * + * Use `$registry->register()` within this action to add new connectors. + * To override an existing connector, unregister it first, then re-register + * with updated data. + * + * 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 ); + * } + * } ); + * + * @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( @@ -430,37 +477,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. - * - * Built-in connectors and any AI providers auto-discovered from the WP AI Client - * registry have already been registered at this point and cannot be unhooked. - * - * AI provider plugins that register with the WP AI Client do not need to use - * this action — their connectors are created automatically. This action is - * primarily for registering non-AI-provider connectors or overriding metadata - * on existing connectors. - * - * Use `$registry->register()` within this action to add new connectors. - * To override an existing connector, unregister it first, then re-register - * with updated data. - * - * 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 ); - * } - * } ); - * - * @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/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index e1bf382aa63ee..314f1ec6673ca 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2400,6 +2400,19 @@ public function test_using_ability_method_chaining() { $this->assertEquals( 500, $config->getMaxTokens() ); } + /** + * Tests that is_supported returns false when prevent prompt filter returns true. + * + * @ticket 64591 + */ + public function test_is_supported_returns_false_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Test prompt' ); + + $this->assertFalse( $builder->is_supported() ); + } + /** * Tests that is_supported returns false when prevent prompt filter returns true. * @@ -2412,7 +2425,6 @@ public function test_is_supported_returns_false_when_filter_prevents_prompt() { $this->assertFalse( $builder->is_supported() ); } - /** * Tests that generate_result returns WP_Error when prevent prompt filter returns true. * 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/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index 161739b7a8ab8..c92e1c6833f86 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -381,4 +381,16 @@ public function test_get_instance_returns_same_instance() { $this->assertSame( $instance1, $instance2 ); } + + /** + * Test registration skips AI connectors when AI is not supported. + */ + public function test_register_skips_when_ai_not_supported() { + add_filter( 'wp_supports_ai', '__return_false' ); + + $this->registry->register( 'first', self::$default_args ); + + $all = $this->registry->get_all_registered(); + $this->assertCount( 0, $all ); + } }