Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
|
Might any of these 3p libraries potentially be used outside of the AI client in the future? ie. they might benefit from being in the higher level Extenders might refer to that 3p code as soon as it's in, so we can't change the namespace at a later date. |
479ebb4 to
a5bd792
Compare
|
FYI there are 50+ |
|
I was fixing that as you posted the comment, @swissspidy. 😄 |
dkotter
left a comment
There was a problem hiding this comment.
Did a fairly quick review here (mostly ignoring everything in the php-ai-client directory as I know that's already been reviewed and tested upstream) and have left a few minor comments.
I was mostly curious to see what, if any, changes would need to be made for those that have already been building on top of the WP AI Client (like we're doing in the AI Experiments plugin). Or if there's anything here that's missing that we'd need to ensure those integrations work (beyond the obvious exclusions in this PR like the provider credentials settings screen).
Not surprisingly, seems there's only super minimal changes we'd make, namely:
- Removing the inclusion of the WP AI Client
- Remove the loading and initialization of the client
- Changing from
AI_Client::prompt_with_wp_errortowp_ai_client_prompt
But looks like everything else we're doing will continue to work, which is great.
We've built many features on top of the (WP) AI Client now in AI Experiments and it's all been working great, so would love to see this merged into core to make it even easier to build on.
There was a problem hiding this comment.
I'm assuming this is probably due to the scoping tool we run here but noticing all the extra line breaks are removed from the php-ai-client files. This may be expected/desired, just makes it a bit harder to read through the code
| $function_id, | ||
| $function_name, | ||
| array( | ||
| 'error' => 'Not an ability function call', |
There was a problem hiding this comment.
I know there's some disagreement around translating error or debug messages but wondering if all the strings in this file should be passed through translation?
There was a problem hiding this comment.
Hmm. I think because this is meant for the AI model we should keep this in English, as that seems to be the best supported language. Open to others' thoughts, though!
src/wp-includes/ai-client-utils/class-wp-ai-client-http-client.php
Outdated
Show resolved
Hide resolved
| $args = $this->prepare_wp_args( $request ); | ||
| $url = (string) $request->getUri(); | ||
|
|
||
| $response = wp_remote_request( $url, $args ); |
There was a problem hiding this comment.
I know there are a number of filters in the WP_Http class that wp_remote_request will make use of, curious if there's any desire around adding more specific filters here, for those that may want to filter arguments (headers, body, etc)?
Again, you can do that right now with the more generic filters upstream and that may be good enough but could make an argument for having wp_ai_client_ filter options here as well to make it easier to only filter when making an AI request
There was a problem hiding this comment.
I personally tend to hold on adding filters until there's a clear use case, but I'm open to adding more filters if others feel there should be more to start.
I'm certainly open to this! What libraries are you thinking of, specifically? |
|
Thanks for looking over this, @dkotter! I've resolved most of what you raised. I'm temporarily shifting to get the settings screen PR in place so we can start testing! |
This adjusts the test coverage workflow to run for any change to a PHP file in a pull request. This is not meant to be included in the final merge, but aims to help gauge the test coverage changes this merge would result in.
|
So I updated the test coverage workflow to run every time this PR is updated (please revert this before a final commit to SVN. In the status checks, you'll find two Surprisingly, it seems that 96%+ of this PR is actually covered by tests (which excludes the bundled library parts of this PR based on the PHPUnit configuration file changes), and this PR actually increases overall test coverages by roughly 2-tenths of a percent. For easier reference, you can view the coverage report for the PR here. Chatting it through with @aaronjorbin, @jeffpaul, and @felixarntz just now, this seems to make sense. The I do feel a bit better about the test coverage part of this. I think this will need to be a blended approach. There should be a high level of coverage for the non-bundled library code here combined with the test coverage within the PHP SDK library. @aaronjorbin has created an issue to follow up with adding test coverage reporting to the |
|
@JasonTheAdams Are you able to add some testing notes to the PR description. I'm having trouble finguring out what needs to be done to add providers to the settings screen with the AI Experiments plugin installed. The menu page is available but without any form fields.
|
|
@peterwilsoncc Done! |
| * @param string $mode Mode with which to open the underlying filename/stream. | ||
| * @return StreamInterface | ||
| */ | ||
| public function createStreamFromFile( string $filename, string $mode = 'r' ): StreamInterface { |
There was a problem hiding this comment.
The interface says:
The file MUST be opened using the given mode, which may be any mode supported by the fopen function.
But on this implementation we ignore the mode and probably just open in read mode all the time (behavior of file_get_contents).
There was a problem hiding this comment.
Nothing in the codebase is using createStreamFromFile, could we remove this implementation?
| break; | ||
| } | ||
|
|
||
| if ( $this->offset < 0 ) { |
There was a problem hiding this comment.
Should we check the upper bound too e.g: against $length.
| * @param string|UriInterface $uri Request URI. | ||
| */ | ||
| public function __construct( string $method, $uri ) { | ||
| $this->method = $method; |
There was a problem hiding this comment.
Should we validate if method is non empty string and matches one of the valid HTTP request methods?
There was a problem hiding this comment.
I believe this is validated elsewhere? But correct me if I'm wrong @JasonTheAdams
| /** | ||
| * Test createStreamFromFile reads file content. | ||
| * | ||
| * @ticket TBD |
There was a problem hiding this comment.
We could already use ticket 64591.
| * | ||
| * @since 7.0.0 | ||
| * | ||
| * @param mixed $prompt Optional. Initial prompt content. Default null. |
There was a problem hiding this comment.
A promp is either string or null correct? Could we replace, mixed with string|null?
There was a problem hiding this comment.
No, there are several formats you can specify a prompt - can be a string, a Message object, multiple Message objects, a list of MessagePart - it has to be flexible to support the simple case (a string), but also the complex case (e.g. next response in a multimodal multi-turn conversation with tool calls).
But great catch, @JasonTheAdams can you add a more comprehensive description here on the possible types?
| declare (strict_types=1); | ||
| namespace WordPress\AiClient\Providers\Models\DTO; | ||
|
|
||
| use InvalidArgumentException; |
There was a problem hiding this comment.
I guess here we should use WordPress\AiClient\Common\Exception\InvalidArgumentException?
| } | ||
|
|
||
| return $result; | ||
| } catch ( Exception $e ) { |
There was a problem hiding this comment.
We are catching only exception so TypeError/Error can still fatal.
Can we catch Throwable here instead of only Exception so invalid argument types also become WP_Error and don’t fatal?
There was a problem hiding this comment.
I'm not sure that's what we want to achieve here. Fatal errors are still fatal errors, also elsewhere in Core.
This is only supposed to catch any "regular", i.e. manually thrown exceptions in the php-ai-client and any underlying third-party libraries that rely on throwing exceptions.
Catching Throwable here would obfuscate fatal errors, and I don't think that's what we want. Especially because Core already has its fatal error detection and recovery mechanism.
|
|
||
| $host = $this->uri->getHost(); | ||
| if ( '' !== $host && ! $this->hasHeader( 'Host' ) ) { | ||
| $this->set_header_internal( 'Host', $host ); |
There was a problem hiding this comment.
UriInterface does not includes port on host, so we are dropping the port from the host header, when is_standard_port is false.
Should we include the port in case the a non standard post is used?
| * @return int Number of bytes written. | ||
| */ | ||
| public function write( string $string ): int { | ||
| $this->content .= $string; |
There was a problem hiding this comment.
Write is always appending, regardless of the tell() seek() positions is this expected? In that case should we just remove tell and seek given that it is ignored?
| public function createStreamFromFile( string $filename, string $mode = 'r' ): StreamInterface { | ||
| $content = file_get_contents( $filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents | ||
|
|
||
| if ( false === $content ) { |
There was a problem hiding this comment.
If there is failure e.g: file does not exist, etc, we just return as if the file existed and was empty? Should we throw an error instead?
| $headers = array(); | ||
|
|
||
| foreach ( $request->getHeaders() as $name => $values ) { | ||
| if ( strpos( $name, 'X-Stream' ) === 0 ) { |
There was a problem hiding this comment.
HTTP headers are case insensitive, we are not matching x-stream, should we use stripos instead?
| * | ||
| * @since 7.0.0 | ||
| */ | ||
| class WP_AI_Client_Cache implements CacheInterface { |
There was a problem hiding this comment.
There was a problem hiding this comment.
Great point!
I would suggest that we apply these annotations to all classes in `ai-client-utils. They're all entirely internal and only exist to wire up the PHP AI Client with WordPress-specific implementations.
| * @since 0.2.0 | ||
| * | ||
| * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, | ||
| * model instances, or [model ID, provider ID] tuples. |
There was a problem hiding this comment.
The order of the tupple is provider ID, model ID I think in this comment we should match what happens in the code.
There was a problem hiding this comment.
Great catch, though this needs to be fixed upstream. I opened WordPress/php-ai-client#204
| * | ||
| * @throws InvalidArgumentException If the array is not a list. | ||
| */ | ||
| public function setFunctionDeclarations(array $function_declarations): void |
There was a problem hiding this comment.
Everywhere else on this file we name parameters in camelCase should we use camelCase here to instead of snake_case?
| * | ||
| * Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set. | ||
| */ | ||
| if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) { |
There was a problem hiding this comment.
Here we have complex bollean I guess we could inlcude "("s to make precedence between and/or clear and make code easier to read.
| */ | ||
| protected function validateMediaOrientationAspectRatioCompatibility(MediaOrientationEnum $orientation, string $aspectRatio): void | ||
| { | ||
| if ($orientation->isSquare() && $aspectRatio !== '1:1') { |
There was a problem hiding this comment.
Nitpick: Should we check $aspectRatioParts[0] == $aspectRatioParts[1] instead of "1:1" e.g: "16:16" would also be a square.
felixarntz
left a comment
There was a problem hiding this comment.
@JasonTheAdams This looks overall quite solid to me. Left a few points of feedback how we can further improve.
| - 'docker-compose.yml' | ||
| - 'phpunit.xml.dist' | ||
| - 'tests/phpunit/multisite.xml' | ||
| - '**.php' |
There was a problem hiding this comment.
TODO: Revert before commit (just leaving this here as a reminder).
| foreach ( $message->getParts() as $part ) { | ||
| if ( $part->getType()->isFunctionCall() ) { | ||
| $function_call = $part->getFunctionCall(); | ||
| if ( $function_call instanceof FunctionCall && self::is_ability_call( $function_call ) ) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return false; |
There was a problem hiding this comment.
nit-pick: You can simplify this by using array_find.
This is a PHP 8.4+ function, but it's available via polyfill in WordPress Core regardless.
| * @param string $ability_name The ability name to convert. | ||
| * @return string The function name. | ||
| */ | ||
| public static function ability_name_to_function_name( string $ability_name ): string { |
There was a problem hiding this comment.
Why is this public? Where is it used? This is a strange API design, given that the reverse function_name_to_ability_name is not public.
| * | ||
| * @since 7.0.0 | ||
| */ | ||
| class WP_AI_Client_Cache implements CacheInterface { |
There was a problem hiding this comment.
Great point!
I would suggest that we apply these annotations to all classes in `ai-client-utils. They're all entirely internal and only exist to wire up the PHP AI Client with WordPress-specific implementations.
| * | ||
| * @since 7.0.0 | ||
| */ | ||
| class WP_AI_Client_Ability_Function_Resolver { |
There was a problem hiding this comment.
Related to https://github.com/WordPress/wordpress-develop/pull/10881/changes#r2807854150, isn't this class actually one that external devs are encouraged to use (to resolve ability function calls)?
If so, I think it actually shouldn't be here, but directly in wp-includes, next to class-wp-ai-client-prompt-builder.php. It makes sense IMO to leave ai-client-utils to internal wiring for the PHP AI Client.
| * | ||
| * @since 7.0.0 | ||
| * | ||
| * @param mixed $prompt Optional. Initial prompt content. Default null. |
There was a problem hiding this comment.
No, there are several formats you can specify a prompt - can be a string, a Message object, multiple Message objects, a list of MessagePart - it has to be flexible to support the simple case (a string), but also the complex case (e.g. next response in a multimodal multi-turn conversation with tool calls).
But great catch, @JasonTheAdams can you add a more comprehensive description here on the possible types?
| * @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. | ||
| * @method self with_message_parts(MessagePart ...$parts) Adds message parts to the current message. | ||
| * @method self with_history(Message ...$messages) Adds conversation history messages. | ||
| * @method self using_model(ModelInterface $model) Sets the model to use for generation. | ||
| * @method self using_model_preference(...$preferredModels) Sets preferred models to evaluate in order. | ||
| * @method self using_model_config(ModelConfig $config) Sets the model configuration. | ||
| * @method self using_provider(string $providerIdOrClassName) Sets the provider to use for generation. | ||
| * @method self using_system_instruction(string $systemInstruction) Sets the system instruction. | ||
| * @method self using_max_tokens(int $maxTokens) Sets the maximum number of tokens to generate. | ||
| * @method self using_temperature(float $temperature) Sets the temperature for generation. | ||
| * @method self using_top_p(float $topP) Sets the top-p value for generation. | ||
| * @method self using_top_k(int $topK) Sets the top-k value for generation. | ||
| * @method self using_stop_sequences(string ...$stopSequences) Sets stop sequences for generation. | ||
| * @method self using_candidate_count(int $candidateCount) Sets the number of candidates to generate. | ||
| * @method self using_function_declarations(FunctionDeclaration ...$functionDeclarations) Sets the function declarations available to the model. | ||
| * @method self using_presence_penalty(float $presencePenalty) Sets the presence penalty for generation. | ||
| * @method self using_frequency_penalty(float $frequencyPenalty) Sets the frequency penalty for generation. | ||
| * @method self using_web_search(WebSearch $webSearch) Sets the web search configuration. | ||
| * @method self using_request_options(RequestOptions $options) Sets the request options for HTTP transport. | ||
| * @method self using_top_logprobs(?int $topLogprobs = null) Sets the top log probabilities configuration. | ||
| * @method self as_output_mime_type(string $mimeType) Sets the output MIME type. | ||
| * @method self as_output_schema(array<string, mixed> $schema) Sets the output schema. | ||
| * @method self as_output_modalities(ModalityEnum ...$modalities) Sets the output modalities. | ||
| * @method self as_output_file_type(FileTypeEnum $fileType) Sets the output file type. | ||
| * @method self as_json_response(?array<string, mixed> $schema = null) Configures the prompt for JSON response output. | ||
| * @method bool|WP_Error is_supported(?CapabilityEnum $capability = null) Checks if the prompt is supported for the given capability. | ||
| * @method bool is_supported_for_text_generation() Checks if the prompt is supported for text generation. | ||
| * @method bool is_supported_for_image_generation() Checks if the prompt is supported for image generation. | ||
| * @method bool is_supported_for_text_to_speech_conversion() Checks if the prompt is supported for text to speech conversion. | ||
| * @method bool is_supported_for_video_generation() Checks if the prompt is supported for video generation. | ||
| * @method bool is_supported_for_speech_generation() Checks if the prompt is supported for speech generation. | ||
| * @method bool is_supported_for_music_generation() Checks if the prompt is supported for music generation. | ||
| * @method bool is_supported_for_embedding_generation() Checks if the prompt is supported for embedding generation. | ||
| * @method GenerativeAiResult|WP_Error generate_result(?CapabilityEnum $capability = null) Generates a result from the prompt. | ||
| * @method GenerativeAiResult|WP_Error generate_text_result() Generates a text result from the prompt. | ||
| * @method GenerativeAiResult|WP_Error generate_image_result() Generates an image result from the prompt. | ||
| * @method GenerativeAiResult|WP_Error generate_speech_result() Generates a speech result from the prompt. | ||
| * @method GenerativeAiResult|WP_Error convert_text_to_speech_result() Converts text to speech and returns the result. | ||
| * @method string|WP_Error generate_text() Generates text from the prompt. | ||
| * @method list<string>|WP_Error generate_texts(?int $candidateCount = null) Generates multiple text candidates from the prompt. | ||
| * @method File|WP_Error generate_image() Generates an image from the prompt. | ||
| * @method list<File>|WP_Error generate_images(?int $candidateCount = null) Generates multiple images from the prompt. | ||
| * @method File|WP_Error convert_text_to_speech() Converts text to speech. | ||
| * @method list<File>|WP_Error convert_text_to_speeches(?int $candidateCount = null) Converts text to multiple speech outputs. | ||
| * @method File|WP_Error generate_speech() Generates speech from the prompt. | ||
| * @method list<File>|WP_Error generate_speeches(?int $candidateCount = null) Generates multiple speech outputs from the prompt. |
There was a problem hiding this comment.
Would be awesome to add a CI check for this, to ensure it always remains 100% aligned with the camelCase methods that the php-ai-client PromptBuilder offers.
Maybe something to ask Claude to come up with? :)
| } | ||
|
|
||
| return $result; | ||
| } catch ( Exception $e ) { |
There was a problem hiding this comment.
I'm not sure that's what we want to achieve here. Fatal errors are still fatal errors, also elsewhere in Core.
This is only supposed to catch any "regular", i.e. manually thrown exceptions in the php-ai-client and any underlying third-party libraries that rely on throwing exceptions.
Catching Throwable here would obfuscate fatal errors, and I don't think that's what we want. Especially because Core already has its fatal error detection and recovery mechanism.
|
|
||
| require_once dirname( __DIR__, 2 ) . '/includes/wp-ai-client-mock-model-creation-trait.php'; | ||
|
|
||
| class Tests_AI_Client_PromptBuilder extends WP_UnitTestCase { |
There was a problem hiding this comment.
The coverage here is already quite good I believe; but I think it would be worth checking what else we can cover, e.g. certain edge cases or other combinations of using* methods that maybe are related, etc. Just to make sure Core itself has sufficient test coverage to reliably catch any future problems that could be caused by updates to the third-party code (including php-ai-client itself).
| @@ -0,0 +1,1343 @@ | |||
| <?php | |||
There was a problem hiding this comment.
I was thinking that it would be useful to have a check that these files (everything in php-ai-client/src and php-ai-client/third-party is not edited in a PR. It may not be obvious to a contributor that this is an external dependency, so that's why this would be useful.
I'm pretty sure we already have something like this when editing Gutenberg block code in Core, but I don't recall where that is controlled. Do you know @desrosj?
In any case, this is not a blocker for completing this PR, but something to think about. If we can find a simple solution, let's go for it. One thing to consider though is that there has to be a way to explicitly opt out of it for a specific PR, if the PR is for the actual update of these dpeendencies.
jorgefilipecosta
left a comment
There was a problem hiding this comment.
This is looking great overall! I left a few comments regarding the code, but other than that, everything looks solid. Nice work! 👏
From a functional standpoint, my tests worked mostly as expected. I did find an issue when testing with the core/get-settings ability. I think we may not be handling the one_of schema for abilities correctly.
Here's a summary of the functional tests I ran:
Setup:
- Checked out #10904 to access the settings screen.
- Installed
google-ai-provideras a test case. - Added an API key at
http://localhost:6888/site-wp-dev-1/wp-admin/options-ai.php.
Basic text generation:
Tried this code sample in functions.php of a test plugin:
add_action( 'wp_loaded', function() {
$summary = wp_ai_client_prompt( 'Remove error from the text' )
->with_text( 'this error is nice error' )
->generate_text();
var_dump( $summary );
exit;
}, 1000 );The result was as expected.
** Single ability (core/get-site-info):**
function my_plugin_ai_answer( string $question ) {
$messages = array(
new \\WordPress\\AiClient\\Messages\\DTO\\UserMessage(
array(
new \\WordPress\\AiClient\\Messages\\DTO\\MessagePart( $question ),
)
),
);
for ( $i = 0; $i < 3; $i++ ) {
$result = wp_ai_client_prompt( $messages )
->using_system_instruction( 'You are a WordPress assistant. Use abilities when needed.' )
->using_abilities(
'core/get-site-info',
)
->generate_text_result();
if ( is_wp_error( $result ) ) {
return $result;
}
$model_message = $result->toMessage();
$messages[] = $model_message;
if ( ! WP_AI_Client_Ability_Function_Resolver::has_ability_calls( $model_message ) ) {
return $result->toText();
}
$messages[] = WP_AI_Client_Ability_Function_Resolver::execute_abilities( $model_message );
}
return new \\WP_Error( 'ai_tool_loop_limit', 'Too many ability rounds.' );
}
// Demo call.
add_action(
'wp_loaded',
function () {
$answer = my_plugin_ai_answer( 'Give me all the site information you could provide.' );
if ( is_wp_error( $answer ) ) {
var_dump(
$answer->get_error_code(),
$answer->get_error_message(),
$answer->get_error_data()
);
} else {
var_dump( $answer );
}
exit;
},
1000
);The result was as expected. Example output:
"I've retrieved the following information about the site:
- Name: Modified Title
- Description: New Tagline
- URL: http://localhost:6888/site-wp-dev-1
- WordPress URL: http://localhost:6888/site-wp-dev-1
- Admin Email: admin@admin.com
- Charset: UTF-8
- Language: en-US
- WordPress Version: 7.0-alpha-20260214.172522"
** Multiple abilities (core/get-site-info + core/get-settings):**
Used the same helper function above but with an additional ability:
->using_abilities(
'core/get-site-info',
'core/get-settings',
)And the prompt: "Give me all the site information and all the setting values you could provide."
This returned an error:
string(20) "prompt_builder_error"
string(460) "Bad Request (400) - Invalid JSON payload received. Unknown name "additionalProperties" at 'tools[0].function_declarations[1].parameters.one_of[0]': Cannot find field.
Invalid JSON payload received. Unknown name "additionalProperties" at 'tools[0].function_declarations[1].parameters.one_of[1]': Cannot find field.
Invalid JSON payload received. Unknown name "additionalProperties" at 'tools[0].function_declarations[1].parameters.one_of[2]': Cannot find field."
array(1) {
["exception_class"]=> string(59) "WordPress\\AiClient\\Providers\\Http\\Exception\\ClientException"
}
I'm not entirely sure yet whether this is a problem within this PR or something invalid in the ability's schema definition. For reference, the ability works fine when using the Gutenberg client-side package, which has strict schema validation:
const abilitiesAPI = (await import( '@wordpress/abilities' ));
await abilitiesAPI.executeAbility('core/get-settings');It's also possible that my test sample has an issue, or that the model is hallucinating and providing an incorrect schema. If it's the latter, it would be great if we could catch that scenario gracefully, either by returning a clearer error message indicating the model provided an invalid schema, or by looping back to the model until it provides a valid one.

Trac ticket: https://core.trac.wordpress.org/ticket/64591
Merge Proposal: https://make.wordpress.org/core/2026/02/03/proposal-for-merging-wp-ai-client-into-wordpress-7-0
Summary
Adds a provider-agnostic AI Client, enabling developers to interact with generative AI services through a single, fluent API — without needing to know which provider is configured.
This PR includes three layers:
PHP AI Client SDK (
php-ai-client): The upstream SDK fromWordPress/php-ai-client, bundled intowp-includes/php-ai-client/with all third-party dependencies (PSR interfaces, HTTPlug) scoped toWordPress\AiClientDependencies\*to avoid conflicts with plugins shipping their own versions.Import tooling (
tools/php-ai-client/): An installer script and PHP-Scoper configuration that fetches, scopes, and reorganizes the SDK for bundling. Runningbash tools/php-ai-client/installer.shreproduces the bundled output deterministically.WP AI Client (
ai-client-utils/,ai-client.php): The WordPress integration layer. This provides minimal PSR-7/PSR-17 implementations backed by string buffers andwp_parse_url(), an HTTP client adapter that routes requests throughwp_remote_request(), a discovery strategy so the SDK automatically finds these implementations, and an event dispatcher that bridges PSR-14 events to WordPress hooks.The public API is a single function:
WP_AI_Client_Prompt_Builderwraps the SDK's fluent builder with WordPress conventions — snake_case methods,WP_Errorreturns instead of exceptions, andusing_abilities()for connecting the Abilities API to AI function calling.The
wp_ai_client_prevent_promptfilter gives site owners and plugins centralized control over AI availability. When a prompt is prevented, generating methods returnWP_Errorwhileis_supported_*methods returnfalse— giving plugin developers a graceful way to hide AI features entirely when AI is not available.This gives plugin and theme developers a stable, provider-neutral way to add AI features without bundling their own HTTP clients or managing provider-specific SDKs.
Testing Instructions
This is strictly the core work and doesn't include a built-in UI for setting up provider credentials. While it is possible to add a provider and set up the credentials, the easiest way to test this is using the stacked settings PR: #10904. That PR has testing instructions and links to provider plugins you can install.
Use of AI Tools
This is a compilation of work from the PHP AI Client and WP AI Client repositories, with some changes made in porting to core. Claude Code was used in both the original development of those packages as well as the porting over and creation of the tooling. All code was generated by Claude Code and reviewed by myself and @felixarntz.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.