WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper
Advanced WordPress Topic
October 24, 2026

Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper

Build a Provider-Agnostic AI Client for WordPress: Anthropic, OpenAI and Gemini Behind One wp_remote_post Wrapper

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 one wp-config migration 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 Authorization instead of x-api-key. Anthropic also rejects requests missing anthropic-version.
  • 404 from Gemini. The model must be URL-encoded and prefixed with models/. A bare gemini-1.5-flash in the body will not work.
  • Empty Anthropic completion. max_tokens is mandatory. Omitting it returns a 400, not a default.
  • cURL error 28. Raise wpth_ai_timeout to 60 and shorten the prompt. Long prompts on shared hosting routinely exceed 30 seconds.
  • Every call fails instantly. Check WP_HTTP_BLOCK_EXTERNAL and any http_request_args filter added by a security plugin.
  • 429 storms. Read the Retry-After header via wp_remote_retrieve_header() and honour it before your backoff fires.
  • Keys in debug.log. You logged $args. Log the status code only.

Next steps

  1. Cache identical prompts in a transient keyed by md5( $provider . $model . $prompt ) with a short TTL.
  2. Add per-user rate limiting with get_transient() before the first wp_remote_post().
  3. Record token usage from each response into a custom table so you can report spend per provider.
  4. Add a second public method, embed(), reusing the same key and retry plumbing.
  5. Write a PHPUnit test that mocks pre_http_request and asserts each provider’s body shape.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Stop Leaking OpenAI Keys in WordPress: Build a REST Proxy with Capability Checks, Rate Limits and Logging
Next Article Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks Expose WordPress to an AI Agent Safely: Read-Only Abilities with the Abilities API and Permission Callbacks
  • Stop leaking OpenAI Keys in WordPress Stop Leaking OpenAI Keys in WordPress: Build a REST Proxy with Capability Checks, Rate Limits and Logging

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed