Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7ff6888
fix: add `wp_supports_ai()` and related constant + filter
justlevine Mar 4, 2026
26d6c92
Merge remote-tracking branch 'upstream' into fix/wp_supports_ai
justlevine Mar 4, 2026
3aa0aae
fix: resolve merge conflicts
justlevine Mar 4, 2026
941cc8b
dev: rename const to WP_AI_SUPPORT
justlevine Mar 4, 2026
78b010b
tests: gate `ReflectionProperty::setAccessible()`
justlevine Mar 4, 2026
a5cef8e
Apply suggestions from code review
justlevine Mar 7, 2026
98b881f
chore: phpcbf
justlevine Mar 7, 2026
e47bedb
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 11, 2026
3ee6aaa
chore: feedback
justlevine Mar 11, 2026
d4b8da5
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 11, 2026
a2d738f
Reuse Prompt type from PromptBuilder in WP_AI_Client_Prompt_Builder c…
westonruter Mar 11, 2026
95a827a
Fix PHPStan error about non-callable being returned
westonruter Mar 11, 2026
b3f04fd
Add type hint for _wp_connectors_register_default_ai_providers()
westonruter Mar 11, 2026
f7f754d
Remove blank line
westonruter Mar 11, 2026
5a06aea
Add void return types
westonruter Mar 11, 2026
2b05524
Remove needless assertion since _wp_connectors_get_connector_settings…
westonruter Mar 11, 2026
ffc7e32
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 12, 2026
414588a
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 12, 2026
d0b4a03
chore: lint after merging
justlevine Mar 12, 2026
007b572
chore: fix test and cleanup
justlevine Mar 12, 2026
bb965aa
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 12, 2026
eda3323
fix: check for support in __call()
justlevine Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/wp-includes/ai-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +21 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( defined( 'WP_AI_SUPPORT' ) && ! WP_AI_SUPPORT ) {
return false;
}
$is_enabled = ! defined( 'WP_AI_SUPPORT' ) || WP_AI_SUPPORT;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this, and it's established in WordPress. Relying on constants without allowing to filter them is problematic for several reasons, including testing.

We shouldn't avoid this pattern because we're scared of some malicious actor enabling AI again via filter. If you have a malicious actor that can do that, you have worse problems.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but weaponization of AI swarms is more than just about immediately API key bills, when you think about the number of abandoned WP sites and our responsibility . And there are dozens of constants in core that have no matching filter.

Copy link
Author

@justlevine justlevine Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding testing, that's the reason the function has a filter. So we just not getting coverage of a single early return. (Conversely, there's no reason to add a constant at all if it's just to serve a default. You can accomplish the same thing with a different-priority hook callback)

Will also repeat the argument that plugins shouldn't be able to override user choice on this, now that we're sub thread and not top-level any more.


/**
* 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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return (bool) apply_filters( 'wp_supports_ai', true );
return (bool) apply_filters( 'wp_supports_ai', WP_AI_SUPPORT );

}

/**
* Creates a new AI prompt builder using the default provider registry.
*
Expand Down
57 changes: 37 additions & 20 deletions src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string|MessagePart|array>|list<Message>|null $prompt Optional. Initial prompt content.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justlevine When reviewing, I found PHPStan complaining about array and then I realized that the desired type is already defined on PromptBuilder. So I reused it here.

* 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 this environment.' ) );
}

$this->builder = new PromptBuilder( $registry, $prompt );
} catch ( Exception $e ) {
$this->builder = new PromptBuilder( $registry );
Expand Down Expand Up @@ -290,26 +297,35 @@ 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.
if ( self::is_support_check_method( $name ) ) {
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(
'exception_class' => 'WP_AI_Client_Prompt_Prevented',
)
Expand Down Expand Up @@ -385,7 +401,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. */
Expand All @@ -396,7 +413,7 @@ protected function get_builder_callable( string $name ): callable {
);
}

return array( $this->builder, $camel_case_name );
return $method;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/wp-includes/class-wp-connector-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] : '',
Expand Down
74 changes: 45 additions & 29 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,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 );
}
Comment on lines +196 to +199
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this even though there's a short-circuit on the registry because this all seems to be hooked onto init for some reason, so loading all the AIClient related stuff seemed unnecessary, but if y'all feel like it's premature optimization we can remove it.


/**
* 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(
Expand Down Expand Up @@ -290,35 +335,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 );
}

/**
Expand Down
21 changes: 21 additions & 0 deletions tests/phpunit/tests/ai-client/wpAiClientPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 this environment.', $error->get_error_message() );
}
}
30 changes: 30 additions & 0 deletions tests/phpunit/tests/ai-client/wpSupportsAI.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php
/**
* Tests for wp_supports_ai().
*
* @group ai-client
* @covers ::wp_supports_ai
*/

class Tests_WP_Supports_AI extends WP_UnitTestCase {
/**
* Test that wp_supports_ai() defaults to true.
*
* @ticket 64591
*/
public function test_defaults_to_true(): void {
$this->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() );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ public function test_each_connector_has_required_fields(): void {
}
}

/**
* 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_get_connectors();
$this->assertSame( array(), $settings );
}

/**
* @ticket 64730
*/
Expand Down
Loading