What you are building
Every AI plugin on the market bolts itself to one vendor. Then the vendor changes a model name, or the client wants Anthropic instead of OpenAI, and you are rewriting request code in five places. A provider-agnostic client fixes that at the source: one class, one public method, three private mappers.
The contract is deliberately small. You call $client->chat( $prompt, $system ) and you get back either a string or a WP_Error. Nothing else leaks out — no vendor SDKs, no Guzzle, no Composer dependency. Just wp_remote_post(), which every WordPress host already allows through its HTTP layer.
Rule of thumb: if a plugin stores an API key in
wp_options, that key is one database export, one staging clone, or onewp-configmigration away from being public. Keys belong in constants.
Prerequisites
- WordPress 6.4 or newer, PHP 7.4+ (8.1 recommended).
- Outbound HTTPS allowed. Some managed hosts set
WP_HTTP_BLOCK_EXTERNAL— check before you debug anything else. - At least one API key from OpenAI, Anthropic or Google AI Studio.
- A theme or small plugin to hold the class. Put it in a plugin so it survives theme switches.
- Familiarity with wp_remote_post() and WP_Error.
Step 1 — Put the keys in wp-config.php
Constants are read at boot, never written to the database, never exposed by the REST API, and never included in a settings export. Add them above the “That’s all, stop editing” line.
// wp-config.php
// Prefer environment variables where your host supports them.
define( 'WPTH_OPENAI_API_KEY', getenv( 'OPENAI_API_KEY' ) ?: 'sk-...' );
define( 'WPTH_ANTHROPIC_API_KEY', getenv( 'ANTHROPIC_API_KEY' ) ?: 'sk-ant-...' );
define( 'WPTH_GEMINI_API_KEY', getenv( 'GEMINI_API_KEY' ) ?: 'AIza...' );
// Optional: cap spend per request.
define( 'WPTH_AI_MAX_TOKENS', 1024 );
Why not the options table? Because get_option() values are dumped by every backup plugin, copied by every staging sync, and rendered by any admin screen that forgets a capability check. Constants also let you run different keys per environment without touching the database.
Step 2 — The class contract
Three things get resolved once in the constructor: which provider is active, which model to use, and how patient the HTTP layer should be. Everything else is a private method.
defined( 'ABSPATH' ) || exit;
final class WPTH_AI_Client {
private string $provider;
private string $model;
private int $timeout;
private int $max_retries;
private const KEY_CONSTANTS = [
'openai' => 'WPTH_OPENAI_API_KEY',
'anthropic' => 'WPTH_ANTHROPIC_API_KEY',
'gemini' => 'WPTH_GEMINI_API_KEY',
];
public function __construct( ?string $provider = null ) {
$this->provider = $this->resolve_provider( $provider );
$this->model = $this->resolve_model();
$this->timeout = (int) apply_filters( 'wpth_ai_timeout', 30 );
$this->max_retries = (int) apply_filters( 'wpth_ai_max_retries', 2 );
}
private function resolve_provider( ?string $provider ): string {
$provider = $provider ?? (string) get_option( 'wpth_ai_provider', 'openai' );
$provider = (string) apply_filters( 'wpth_ai_provider', $provider );
return array_key_exists( $provider, self::KEY_CONSTANTS ) ? $provider : 'openai';
}
private function resolve_model(): string {
$defaults = [
'openai' => 'gpt-4o-mini',
'anthropic' => 'claude-3-5-sonnet-latest',
'gemini' => 'gemini-1.5-flash',
];
$model = $defaults[ $this->provider ] ?? '';
return (string) apply_filters( 'wpth_ai_model', $model, $this->provider );
}
private function api_key(): string {
$name = self::KEY_CONSTANTS[ $this->provider ] ?? '';
if ( '' === $name || ! defined( $name ) ) {
return '';
}
return trim( (string) constant( $name ) );
}
private function endpoint(): string {
switch ( $this->provider ) {
case 'anthropic':
return 'https://api.anthropic.com/v1/messages';
case 'gemini':
return sprintf(
'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent',
rawurlencode( $this->model )
);
default:
return 'https://api.openai.com/v1/chat/completions';
}
}
}
Note the Gemini quirk: the model name is part of the URL, not the body. That is why resolve_model() runs before endpoint() and why the model is rawurlencode()d.
Step 3 — Per-provider request mapping
All three vendors accept a system prompt and a list of user/assistant turns, but they disagree on shape. OpenAI uses a messages array with a system role. Anthropic takes a top-level system string and requires max_tokens. Gemini uses contents with parts and calls the assistant role model.
private function build_body( string $system, array $messages ): array {
switch ( $this->provider ) {
case 'anthropic':
$body = [
'model' => $this->model,
'max_tokens' => (int) ( defined( 'WPTH_AI_MAX_TOKENS' ) ? WPTH_AI_MAX_TOKENS : 1024 ),
'messages' => $messages,
];
if ( '' !== $system ) {
$body['system'] = $system;
}
break;
case 'gemini':
$body = [
'contents' => array_map(
static function ( array $m ): array {
return [
'role' => ( 'assistant' === $m['role'] ) ? 'model' : 'user',
'parts' => [ [ 'text' => (string) $m['content'] ] ],
];
},
$messages
),
];
if ( '' !== $system ) {
$body['systemInstruction'] = [ 'parts' => [ [ 'text' => $system ] ] ];
}
break;
default:
$body = [
'model' => $this->model,
'messages' => $messages,
];
if ( '' !== $system ) {
array_unshift( $body['messages'], [ 'role' => 'system', 'content' => $system ] );
}
}
return (array) apply_filters( 'wpth_ai_request_body', $body, $this->provider, $this->model );
}
The wpth_ai_request_body filter is your escape hatch for temperature, top-p, JSON mode or safety settings without touching the class again.
Step 4 — Per-provider response mapping
Headers differ too. OpenAI wants a bearer token, Anthropic wants x-api-key plus a pinned anthropic-version, and Gemini accepts x-goog-api-key so the key never lands in a URL that could end up in an access log.
private function headers(): array {
$key = $this->api_key();
switch ( $this->provider ) {
case 'anthropic':
return [
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
];
case 'gemini':
return [
'x-goog-api-key' => $key,
'Content-Type' => 'application/json',
];
default:
return [
'Authorization' => 'Bearer ' . $key,
'Content-Type' => 'application/json',
];
}
}
private function extract_text( array $decoded ): string {
switch ( $this->provider ) {
case 'anthropic':
$text = '';
foreach ( (array) ( $decoded['content'] ?? [] ) as $part ) {
if ( isset( $part['type'], $part['text'] ) && 'text' === $part['type'] ) {
$text .= $part['text'];
}
}
return $text;
case 'gemini':
$text = '';
foreach ( (array) ( $decoded['candidates'][0]['content']['parts'] ?? [] ) as $part ) {
$text .= (string) ( $part['text'] ?? '' );
}
return $text;
default:
return (string) ( $decoded['choices'][0]['message']['content'] ?? '' );
}
}
Step 5 — wp_remote_post with timeout, retry and WP_Error
This is the only place that touches the network. It retries transport failures, 429 and 5xx with exponential backoff, and returns a typed WP_Error for everything else so callers can branch on the error code instead of parsing strings.
public function chat( string $prompt, string $system = '', array $history = [] ) {
if ( '' === $this->api_key() ) {
return new WP_Error(
'wpth_ai_missing_key',
sprintf( 'No API key constant defined for provider "%s".', $this->provider )
);
}
$messages = $history;
$messages[] = [ 'role' => 'user', 'content' => $prompt ];
$args = [
'timeout' => $this->timeout,
'headers' => $this->headers(),
'body' => wp_json_encode( $this->build_body( $system, $messages ) ),
];
$attempt = 0;
do {
$attempt++;
$response = wp_remote_post( $this->endpoint(), $args );
if ( is_wp_error( $response ) ) {
$error = $response;
} else {
$code = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( $code >= 200 && $code < 300 ) {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
return new WP_Error( 'wpth_ai_bad_json', 'Provider returned a non-JSON body.' );
}
$text = trim( $this->extract_text( $decoded ) );
if ( '' === $text ) {
return new WP_Error( 'wpth_ai_empty', 'Provider returned an empty completion.' );
}
return $text;
}
$error = new WP_Error(
'wpth_ai_http_' . $code,
$this->error_message( $raw, $code ),
[ 'status' => $code ]
);
}
if ( ! $this->is_retryable( $error ) || $attempt > $this->max_retries ) {
return $error;
}
sleep( 2 ** ( $attempt - 1 ) ); // 1s, 2s, 4s.
} while ( true );
}
private function is_retryable( WP_Error $error ): bool {
$code = $error->get_error_code();
if ( 'wpth_ai_http_429' === $code ) {
return true;
}
if ( preg_match( '/^wpth_ai_http_5\d\d$/', $code ) ) {
return true;
}
return in_array( $code, [ 'http_request_failed', 'http_request_timeout' ], true );
}
Keep max_retries low. A synchronous admin action can afford one backoff; a front-end request cannot. If you call this from a page render, push the work to an async request or a cron event instead.
Step 6 — Debug logging behind WP_DEBUG
Log the status code and the vendor’s error message, never the headers and never the prompt. Both can contain personal data or a key fragment.
private function error_message( string $raw, int $code ): string {
$decoded = json_decode( $raw, true );
$detail = '';
if ( is_array( $decoded ) ) {
$detail = (string) ( $decoded['error']['message'] ?? $decoded['error']['status'] ?? '' );
}
$this->log( sprintf( 'HTTP %d: %s', $code, $detail ?: substr( $raw, 0, 300 ) ) );
return $detail ?: sprintf( 'Provider returned HTTP %d.', $code );
}
private function log( string $message ): void {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
if ( ! apply_filters( 'wpth_ai_debug_log', true, $this->provider ) ) {
return;
}
error_log( sprintf( '[WPTH AI][%s] %s', $this->provider, $message ) );
}
On production, WP_DEBUG is false and the logger is a no-op — no disk writes, no leaked prompts in debug.log.
Step 7 — A settings screen that only picks a provider
The screen stores one string. No key field, no model field, no “test connection” button that would echo the key back into the DOM.
add_action( 'admin_init', function (): void {
register_setting( 'wpth_ai', 'wpth_ai_provider', [
'type' => 'string',
'default' => 'openai',
'sanitize_callback' => static function ( $value ): string {
return in_array( $value, [ 'openai', 'anthropic', 'gemini' ], true ) ? $value : 'openai';
},
] );
} );
add_action( 'admin_menu', function (): void {
add_options_page( 'AI Provider', 'AI Provider', 'manage_options', 'wpth-ai', 'wpth_render_ai_settings' );
} );
function wpth_render_ai_settings(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$current = get_option( 'wpth_ai_provider', 'openai' );
$labels = [
'openai' => 'OpenAI',
'anthropic' => 'Anthropic',
'gemini' => 'Google Gemini',
];
?>
<div class="wrap">
<h1>AI Provider</h1>
<form method="post" action="options.php">
<?php settings_fields( 'wpth_ai' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="wpth_ai_provider">Provider</label></th>
<td>
<select name="wpth_ai_provider" id="wpth_ai_provider">
<?php foreach ( $labels as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $current, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">Keys live in <code>wp-config.php</code>. Nothing secret is saved here.</p>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
The sanitize_callback is an allow-list, not a regex. Anything that is not one of the three known slugs collapses to openai, so a crafted POST cannot force an unexpected code path.
Step 8 — Swap models with a filter
Model names age fast. Because resolve_model() already runs through wpth_ai_model, a site can pin a cheaper model per post type without editing the class.
add_filter( 'wpth_ai_model', function ( string $model, string $provider ): string {
if ( 'anthropic' === $provider && is_singular( 'post' ) ) {
return 'claude-3-5-haiku-latest';
}
return $model;
}, 10, 2 );
// Usage anywhere after plugins_loaded.
$client = new WPTH_AI_Client();
$result = $client->chat(
'Summarise this post in 40 words: ' . wp_strip_all_tags( $post->post_content ),
'You are a terse editor. Output plain text only.'
);
if ( is_wp_error( $result ) ) {
error_log( $result->get_error_code() . ': ' . $result->get_error_message() );
} else {
update_post_meta( $post->ID, '_wpth_summary', wp_kses_post( $result ) );
}
Wiring it into a block, pattern or REST route
Store the output in post meta with show_in_rest => true, then render it through a bound paragraph in a synced pattern rather than echoing raw HTML. If you must expose it over a custom REST route, return the string and let the block escape it with esc_html() — never inject model output with innerHTML. Model text is untrusted input; treat it exactly like a comment body.
Troubleshooting
- 401 from Anthropic. You sent
Authorizationinstead ofx-api-key. Anthropic also rejects requests missinganthropic-version. - 404 from Gemini. The model must be URL-encoded and prefixed with
models/. A baregemini-1.5-flashin the body will not work. - Empty Anthropic completion.
max_tokensis mandatory. Omitting it returns a 400, not a default. cURL error 28. Raisewpth_ai_timeoutto 60 and shorten the prompt. Long prompts on shared hosting routinely exceed 30 seconds.- Every call fails instantly. Check
WP_HTTP_BLOCK_EXTERNALand anyhttp_request_argsfilter added by a security plugin. - 429 storms. Read the
Retry-Afterheader viawp_remote_retrieve_header()and honour it before your backoff fires. - Keys in
debug.log. You logged$args. Log the status code only.
Next steps
- Cache identical prompts in a transient keyed by
md5( $provider . $model . $prompt )with a short TTL. - Add per-user rate limiting with
get_transient()before the firstwp_remote_post(). - Record token usage from each response into a custom table so you can report spend per provider.
- Add a second public method,
embed(), reusing the same key and retry plumbing. - Write a PHPUnit test that mocks
pre_http_requestand asserts each provider’s body shape.
Leave a Reply