Verified on WordPress 7.0 RC3, and re-confirmed on the 7.0 stable release. PHP 8.3.30. Because I implemented this against RC3, I'm writing with the assumption that the AI Client API may still move — but the behaviors described here (with_history()'s type, the role enum, the decryption-notice path) were identical between RC3 and stable.
WordPress 7.0 shipped with the AI Client built into core. If a site admin wires up a provider in Connectors, a plugin can reach the model behind it without carrying any API keys of its own.
My chatbot plugin used to carry OpenAI, Claude, Gemini, and OpenRouter itself — encrypting each provider's key, picking models, normalizing responses. I wrote that whole layer by hand. The 7.0 AI Client was an offer to hand that layer to the platform. I wanted to take it: keys and models, out of the plugin, into Connectors.
There was one tension. Most users are still on pre-7.0 WordPress. I could add a new provider, but I couldn't break a single line on older installs. The new path had to lie dormant on top of an AI Client that doesn't exist yet.
The migration itself should have been just accepting the offer: detect, connect, stay asleep on old environments. Not hard, I thought.
What actually ate my time was none of those. It was a single spot.
Passing history to with_history() — that one spot
It was passing the conversation history to with_history(). That's where I fell, twice.
The first fall was the loud one. The plugin keeps history as a plain ChatML-style array — ['role' => 'user', 'content' => '…']. I passed it straight through.
// the plugin's internal history (a plain ChatML-style array)
$history = [
['role' => 'user', 'content' => "What's the weather in Tokyo?"],
['role' => 'assistant', 'content' => "It's sunny."],
['role' => 'user', 'content' => "And in Osaka?"],
];
$response = wp_ai_client_prompt( "And in Osaka?" )
->with_history( $history ) // passing the array as ONE argument
->generate_text();
This doesn't work. The AI Client's withHistory() is withHistory( Message ...$messages ) — a typed variadic that expects a sequence of Message objects. Hand it one raw array and it tries to bind that array to $messages[0], which raises a TypeError (array given, Message expected).
I half-expected the WordPress wrapper to catch this. It doesn't. WP_AI_Client_Prompt_Builder::__call only catches Exception. A TypeError is an Error, not an Exception, so it sails right past the catch and blows up loudly. Which, it turns out, was the kind of failure I should be grateful for. You notice it immediately.
The second fall was the quiet one.
Having crashed loudly, I wrote code that builds Message objects correctly: fold roles to user/model, wrap each text in a MessagePart, put that in an array, pass it to UserMessage/ModelMessage, and finally spread it into with_history().
use WordPress\AiClient\Messages\DTO\UserMessage;
use WordPress\AiClient\Messages\DTO\ModelMessage;
use WordPress\AiClient\Messages\DTO\MessagePart;
private function apply_history( array $history ): array {
$messages = [];
foreach ( $history as $turn ) {
$text = (string) ( $turn['content'] ?? '' );
if ( '' === $text ) {
continue; // skip empty turns
}
$part = new MessagePart( $text ); // a single string => a text part
// fold everything that isn't "user" into "model" (two-value coercion)
$messages[] = ( ( $turn['role'] ?? '' ) === 'user' )
? new UserMessage( [ $part ] )
: new ModelMessage( [ $part ] );
}
return $messages;
}
$messages = $this->apply_history( $history );
$response = wp_ai_client_prompt( $latest_user_text )
->with_history( ...$messages ) // spread into the variadic
->generate_text();
(For readability I'm using use here. The production code references MessagePart etc. by string class name behind class_exists() — it actually checks all three of MessagePart / UserMessage / ModelMessage — so the file doesn't fatal on pre-7.0 sites where the DTO classes don't exist. That guard is exactly what sets up the real trap.)
One honest note about the role folding. Collapsing everything non-user into model isn't sloppiness; it's a bet leaning on an assumption. The AI Client's role enum has only two values, user and model. System goes through a separate path (using_system_instruction), and system messages are split off upstream — so what reaches apply_history() is effectively just user and assistant/bot. The two-value coercion holds. But it holds because of that assumption. If an input ever violates it, a system turn silently becomes a model turn. Safe today — but the safety lives in the precondition, not in the code.
And the real trap was past this point.
I had the marshalling right. Build the Message, wrap it, spread it. The first-fall TypeError was gone. Relaxing there was how I walked into the second fall.
The production code wraps that same marshalling in a try/catch. The reason is pre-7.0 compatibility: old WordPress doesn't have the AI Client DTO classes, so if anything throws mid-build, I don't want to fatal the whole site. So I check class_exists() for the DTOs, wrap the whole construction in try/catch(\Throwable), and if anything goes wrong, abandon the history entirely and move on.
// production structure (simplified)
if ( ! class_exists( $message_part_class ) ) {
// …log only when WP_DEBUG…
return; // return without building history
}
try {
// …build MessagePart / UserMessage / ModelMessage and call with_history() …
} catch ( \Throwable $e ) {
// …log only when WP_DEBUG…
return; // return without building history
}
The design is sound. Better to drop the context once and still answer than to crash on an old install. That was the call. The problem is that this defense never tells anyone it gave up.
apply_history() returns void. When it takes the skip path, with_history() is never called. What's left on the builder is the system instruction plus the single latest turn that went into wp_ai_client_prompt(). generate_text() returns 200 like nothing happened. The conversation looks fine. It just doesn't remember two turns ago. No error log. No exception. The response is valid. The only thing missing is the context.
And the thing that actually fires in production isn't the class_exists() branch. Once wp_ai_client_prompt() exists, the AI Client is loaded, so the DTOs autoload fine. class_exists() going false is the latent trap — for when the SDK moves a namespace or changes structure down the line. The branch that actually drops history is try/catch(\Throwable): if the SDK's signature shifts even a little (the UserMessage argument shape, the with_history type, the role enum), the marshalling you wrote correctly throws, gets caught here, and the history quietly disappears.
Here's the clincher on the silence. Both skip paths log only inside if ( defined('WP_DEBUG') && WP_DEBUG ). Production has WP_DEBUG off. So the defensive code does write a log — and the log itself goes silent in production. The safety net swallows the failure, and the alarm that was supposed to announce it is switched off behind a flag. The well-meaning defense muted itself twice. The failure happens. The signal that it happened goes nowhere.
The difference between the first fall and the second was, in the end, the guard itself. The unguarded minimal repro crashes with a TypeError and you notice immediately. The guarded production code swallows the same failure and calmly returns a response with the context stripped out. Crashing would have been the kinder behavior.
How I actually noticed this — I no longer have the exact record. But my verification notes from the time had "multi-turn conversation returns a reply that accounts for earlier turns" standing as its own checklist item. This is the kind of break that doesn't show up in unit tests and only surfaces when you talk to it across several turns by hand, so I suspect I hit it somewhere in that manual check.
The keyless provider broke an assumption the whole plugin was built on
Past the silence of with_history(), a completely different kind of problem was waiting. Not about types. The AI Client broke an assumption the entire plugin held without anyone ever writing it down: every provider has an API key inside the plugin.
wpai hands its key off to Connectors. There is no wpai API key inside the plugin. That one fact surfaced from places I never expected. From the shallow end:
The shallowest hole: the settings schema
First it was just teaching the schema a new provider. Without wpai in the allowlist, in_array rejects it and the selection resets (an invalid value falls back to the previously saved value, or openai if none). Add wpai_model to sanitize, give it an empty default. The dull kind of work that breaks if you forget it.
But there's something to notice here. Every other provider has both _api_key and _model. wpai has only _model.
openai : openai_api_key + openai_model
openrouter : openrouter_api_key + openrouter_model
wpai : (no key field) + wpai_model
There is no wpai_api_key anywhere. Being keyless is visible as a hole in the settings schema — the quietest tear in the "every provider has a key field" assumption. At this point I didn't realize it was the omen of the two below.
The middle: the REST pre-check
The chat REST pre-check decrypts the active provider's key before the call and, if it's empty, returns 400 api_key_missing and bails early. "Confirm the key exists before calling." Reasonable — no point shipping a request to the model without a key.
wpai doesn't fit that. Having no key is normal for it, so I wrapped the whole pre-check in a name-based exempt: not a capability test, just !== 'wpai'.
What happened there wasn't only an exemption — it was a move of the availability check. Other providers verify availability "before the call, by key." wpai verifies it "at call time, by Connector availability," delegated to is_supported_for_text_generation(). The same "is this usable?" question shifted from a key-existence check to a call-time capability check. An assumption learned an exception.
The deepest: the decryption-failure notice
This is the heart of the chapter. An admin notice, "Failed to decrypt the API key," was misfiring for wpai users. At first I thought it was a gap in my wpai handling. It wasn't. wpai only dragged into the light something that had been broken for far longer.
The misfire was a three-step chain. A migration routine that runs every time the settings page loads (maybe_migrate_legacy_keys()) sweeps every provider's key. Inside that loop, it called a decrypt wrapper that raises a global transient on failure. And the notice's display check looked only at that transient plus a capability — and displayed unconditionally. It never asked which provider failed, or whether that provider was the active one.
So: a user who switched to OpenAI still has an old Claude key sitting around that can no longer be decrypted after a salt change. They open the settings page. The migration touches that dead key, a global alarm goes up. They're running OpenAI just fine, but they catch a "decryption failed" because of a Claude key they abandoned. The alarm is ringing correctly. It's just pointing at the wrong thing. The signal is emitted — but the truth it points to is the wrong truth.
Here's what clicked. This misfire was never a bug wpai introduced. It became possible the moment the design allowed multiple providers' keys to be stored at once — one active provider, but any old key could raise the global alarm. Nobody hit that path, so nobody noticed. Adding a keyless provider, and finally asking "whose key is this, and is it the active one?", was what surfaced it.
So I didn't fix it by adding a wpai exception. I generalized the display check into a four-gate test.
// notice display check: "is the ACTIVE provider's own key actually the problem?"
if (active === 'wpai') return; // (1) keyless => plugin-side key is irrelevant
if (active's *_api_key is empty) return; // (2) never set
if (active's own key decrypts fine) return; // (3) failure belongs to some other unused provider
// (4) only here: the active provider's own key is genuinely broken
show_notice();
=== 'wpai' isn't a tacked-on exception. It's the first branch of the question "is the active provider's own key actually the problem?" The ordering itself says so.
The takeaway, I think, is this. Adding a new abstraction doesn't just cost you N new exceptions. Sometimes it surfaces the fact that an invariant was never holding in the first place. wpai broke one of my two unspoken assumptions — "every provider has a key" — and taught me the other one, "a decryption failure means the one key is broken," had been false all along.
The assumption that "every provider has an API key" was false from the start.
Retrying once on the temperature 400
That was the heavy stuff. The last one is a humbler move, worth recording.
When the model behind Connectors is GPT-5 or an o-series, it won't accept a custom temperature and returns 400. Those models have a fixed temperature, so the value you specify gets rejected. Known behavior.
The handling is grubby. If the first generation returns an error, and the error body contains the word temperature, and I actually specified a custom temperature — only when all three line up, I strip temperature and send once more.
// first attempt: with temperature
$text = $this->build_prompt($prompt, $system, $history, $options, false)->generate_text();
// only if the error body contains "temperature" AND I set a temperature: drop it and retry, once
if (is_wp_error($text)
&& stripos($text->get_error_message(), 'temperature') !== false
&& isset($options['temperature'])
) {
$text = $this->build_prompt($prompt, $system, $history, $options, true)->generate_text();
}
// no loop, no recursion, no flag. "exactly once" is guaranteed by the structure.
The humbleness is in two places.
One: the decision is a substring match on a human-readable message, not a structured code. stripos just fishes for the word temperature. It bets on the wording of the message, not a stable contract like an error code. The third isset guard pulls its weight — if a temperature error shows up when I never set one, it won't waste a retry.
Two: there's nothing watching to enforce "exactly once." No loop, no recursion, no retry flag. The retry is a single if block, and the strip is build_prompt() with $skip_temperature = true, which omits the using_temperature() call entirely — not resetting to 1.0, just dropping the parameter and deferring to the model default. Straight-line code, so there's nothing to bound.
But, to be honest: because this is a substring match on the error body, the day the SDK rewords or localizes that message, it stops working silently. A signal that arrives today, gone one day without telling anyone. It's a bet on a known, sufficiently stable quirk — made knowingly.
A note to my next self
The three sections are different stories — a story about types, about invariants, about a substring match. But re-reading them, the same shape runs underneath. In each one, the breakage happened. What went wrong was that the signal of it had come apart from the truth.
Pass a raw array to with_history() and it crashes loudly. But wrap it in a compatibility defense and the failure starts quietly dropping the history, and the log that would say so never reaches anyone behind WP_DEBUG. The signal is absent. The decryption notice is the inverse: the alarm rings fine — it just points at an old key you don't even use anymore. The signal points at the wrong truth. The temperature substring match works today and, the day the SDK rewords the message, stops working without a word. The signal vanishes.
In every case, the breakage didn't get communicated correctly.
To write this, I went looking for how I noticed all this at the time. I re-read the code, traced the commit log, checked the records of my work sessions and the verification notes I'd left. Nothing. The moment of noticing isn't recorded anywhere. What remained was the way I'd written the defensive comments and where I'd placed the try/catch — the marks left in the code, and only those.
What carried my past self's decisions to my future self wasn't memory. It was the code.
If that's true, then the defensive code and comments I write today are a note to a self who will have forgotten all of it. Not a resolution — just a fact. So:
Give the safety net an alarm that actually rings. Don't hide the log behind WP_DEBUG. When you fall back to a default — dropping history, folding a role, swinging at a string — leave a mark so the fallback can be heard. What breaks quietly also robs you of the chance to fix it quietly.
The next self to open this will probably remember none of it. The only thing that remembers is the code.
Originally published on raplsworks.com. The broader read on the WP AI Client API and the migration plan for this plugin is in the companion piece.
















