Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
31 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
e0719a0
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 16, 2026
e8387df
chore: post merge cleanup
justlevine Mar 16, 2026
bd314e7
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 16, 2026
9773705
tests: test `Registry->get_all_registered()` instead of downstream
justlevine Mar 16, 2026
069e6d4
chore: revert `__call()` check in favor of constructor
justlevine Mar 16, 2026
4f43aad
chore: move support check to __call()
justlevine Mar 18, 2026
0d1ffb9
tests: remove unnecessary test
justlevine Mar 18, 2026
9126d8b
dev: allow filter to override constant
justlevine Mar 18, 2026
bcb364b
Merge branch 'trunk' into fix/wp_supports_ai
justlevine Mar 18, 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
21 changes: 21 additions & 0 deletions src/wp-includes/ai-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@

use WordPress\AiClient\AiClient;

/**
* Returns whether AI features are supported in the current environment.
*
* @since 7.0.0
Comment thread
justlevine marked this conversation as resolved.
*/
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;
}
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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 );
Comment thread
justlevine marked this conversation as resolved.
Outdated
}

/**
* Creates a new AI prompt builder using the default provider registry.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ class WP_AI_Client_Prompt_Builder {
*/
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.' ) );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

WordPress should not throw exceptions. This is a discouraged pattern that we shouldn't randomly start here.

Instead, we need to incorporate this check right above the other filter in this class to see whether a specific prompt is supported: Because, if AI is disabled, the prompt for sure is not supported.

This also addresses my primary concern voiced in several places before: We should not require devs to make two checks. They should simply be able to call the relevant is_supported method, and if it returns false, they know that they can't (or shouldn't) expose their AI feature to users.

Including this check in the existing central place for checking AI capability/prompt support is critical.

Copy link
Copy Markdown
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.

@felixarntz can you please clarify, perhaps with a code sample what you're trying to say?
I'm not following... I didn't add that catch {} block on line 189 that turns exceptions into errors it's already in the code. Do you just want me to change this line to it's own $this->error = new \WP_Error() and early return instead of keeping it DRY? Because if so I don't mind, but im not sure if that would actually address what your issue is.

And I'm not by computer to double check right not but IIRC and as previously noted ::is_supported() works just as before, if AI is disabled, it will return false, because the internal WP_Error is 'AI Features are not suppored in the current environment.' per L288 (also previously in this codebase)....

Copy link
Copy Markdown
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.

@felixarntz I think I figured out what you meant - please take a look at and let me know if this works: eda3323

If I understood correctly, the part I was missing is that there's no guarantee the constructor will be called before the first usage of an ::is_supported() call. I had misunderstood the class as a singleton and thought there had to be an attempt to create the builder instance before any __call() could be made.

If that's still not it, please lmk 🤔

Copy link
Copy Markdown
Author

@justlevine justlevine Mar 17, 2026

Choose a reason for hiding this comment

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

Nope that wasn't it... I was sleep deprived and didnt realize that ::is_supported() is an instance call, and not a static one.

I reverted the change in 069e6d4 which also adds a test showing that $builder->is_supported() returns false identically to if someone set the *_prevent_prompt filter. As you can see it's passing.

I did some more searching, and see quite a few uses cases of exception throwing, not the least in our own core ai work, so now I'm even more puzzled about your concern here.

image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What I mean is we should move this check into the __call method, right before the wp_ai_client_prevent_prompt filter. This is where we already check for whether a prompt is supported, and disabling AI clearly influences this.

If AI is disabled, it should behave the same way as if that filter returned false (while bypassing the actual filter, because it's pointless to run then).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@felixarntz did as you requested in 4f43aad .

IMO it's an unnecessary change that hurts readability and makes the cognitive complexity of __call() worse for no gain, but as RC1 ships tomorrow 🤷

If AI is disabled, it should behave the same way as if that filter returned false

This was always true...

}

$this->builder = new PromptBuilder( $registry, $prompt );
} catch ( Exception $e ) {
$this->builder = new PromptBuilder( $registry );
Expand Down
4 changes: 4 additions & 0 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca
* }
*/
function _wp_connectors_get_connector_settings(): array {
if ( ! wp_supports_ai() ) {
return array();
}

$connectors = array(
'anthropic' => array(
'name' => 'Anthropic',
Expand Down
19 changes: 19 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,23 @@ 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() {
// 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' );
$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() );
}
}
40 changes: 40 additions & 0 deletions tests/phpunit/tests/ai-client/wpSupportsAI.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
/**
* Tests for wp_supports_ai().
*
* @group ai-client
* @covers ::wp_supports_ai
*/

class Tests_WP_Supports_AI extends WP_UnitTestCase {
/**
* {@inheritDoc}
*/
Comment thread
justlevine marked this conversation as resolved.
Outdated
public function tear_down() {
// Remove the WP_DISABLE_AI constant if it was defined during tests.
remove_all_filters( 'wp_supports_ai' );

parent::tear_down();
}

/**
* Test that wp_supports_ai() defaults to true.
*
* @ticket 64591
*/
public function test_defaults_to_true() {
$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() {
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 @@ -115,4 +115,18 @@ public function test_includes_registered_provider_from_registry() {
$this->assertNull( $mock['authentication']['credentials_url'] );
$this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] );
}



Comment thread
justlevine marked this conversation as resolved.
Outdated
/**
Comment thread
justlevine marked this conversation as resolved.
Outdated
* Tests connectors return an empty array when AI is not supported
*/
public function test_returns_empty_array_when_ai_not_supported() {
// Temporarily disable AI support for this test.
add_filter( 'wp_supports_ai', '__return_false' );

$settings = _wp_connectors_get_connector_settings();
$this->assertIsArray( $settings );
$this->assertEmpty( $settings );
}
}
Loading