Integration

Building a Custom Provider for the PHP AI Client SDK

⏱ 22 min

Course 5 taught wp_ai_client_prompt() as a front door, and the three official provider plugins as what stands behind it. This lesson opens that door from the inside. The wordpress/php-ai-client package (the library those provider plugins, and WordPress 7.0’s wp_ai_client_prompt() wrapper, are both built on) defines a real ProviderInterface and a real ProviderRegistry that any provider registers with, not just the three official ones. Registering a class there is exactly what an official provider plugin does, and it’s exactly what makes your Lesson 2 or Lesson 3 endpoint usable through the same wp_ai_client_prompt() calls Course 5 already taught you.

Every class name, method signature, and namespace in this lesson was read directly from the WordPress/php-ai-client GitHub repository’s source and test suite, not guessed from its README, since shipping inaccurate interface code here would be worse than not covering this topic at all.

What you'll learn in this lesson
The real ProviderInterface contract
metadata(), model(), availability(), and modelMetadataDirectory(), the four methods any provider implements.
Why you extend AbstractOpenAiCompatible* base classes instead of ProviderInterface directly
The SDK already ships OpenAI-compatible base classes built for exactly this situation.
Registering with ProviderRegistry
AiClient::defaultRegistry()->registerProvider(), the same call an official provider plugin makes.
The API-key quirk from Lesson 2, resolved properly
Why you declare API-key authentication even for a provider that doesn't check it, and how the SDK auto-wires it from an environment variable or constant.
Prerequisites

Lesson 2 or 3’s running local endpoint, and Course 5’s PHP AI Client SDK setup lesson. You’ll also need a PSR-18 HTTP client package installed alongside wordpress/php-ai-client, since the SDK discovers one automatically via php-http/discovery but doesn’t ship one itself.

Step 1: Install the SDK and an HTTP client

# File: terminal, from your plugin's directory
composer require wordpress/php-ai-client php-http/curl-client nyholm/psr7

php-http/curl-client and nyholm/psr7 give php-http/discovery a real PSR-18 client and PSR-17 factories to find at runtime. Without one of these (or an equivalent like Guzzle), the registry can’t send any HTTP requests at all, local or cloud.

Step 2: The real ProviderInterface

Every provider, official or custom, implements this interface (from src/Providers/Contracts/ProviderInterface.php in the SDK):

// File: vendor/wordpress/php-ai-client/src/Providers/Contracts/ProviderInterface.php (reference, not written by you)
interface ProviderInterface
{
    public static function metadata(): ProviderMetadata;
    public static function model( string $modelId, ?ModelConfig $modelConfig = null ): ModelInterface;
    public static function availability(): ProviderAvailabilityInterface;
    public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface;
}

You won’t implement this interface directly. The SDK ships an AbstractProvider base class that implements it for you, plus an AbstractApiProvider that adds base-URL handling for any REST-backed provider, plus AbstractOpenAiCompatibleTextGenerationModel and AbstractOpenAiCompatibleModelMetadataDirectory, purpose-built for exactly the situation Ollama, vLLM, and llama.cpp all put you in: a provider that speaks OpenAI’s chat-completions request and response shape. Building on these means the only code you write is the handful of methods that are genuinely specific to your endpoint.

Four classes, four small responsibilities
1
A model class
Extends AbstractOpenAiCompatibleTextGenerationModel, implements one method: how to build a Request pointed at your endpoint.
2
A model metadata directory
Extends AbstractOpenAiCompatibleModelMetadataDirectory, lists which models your endpoint currently has available.
3
An availability checker
A small class implementing ProviderAvailabilityInterface, is the endpoint actually reachable right now.
4
The provider itself
Extends AbstractApiProvider, wires the previous three together and declares the base URL.

Step 3: The model class

// File: my-plugin/includes/ollama-provider/class-ollama-text-generation-model.php
namespace MyPlugin\OllamaProvider;

use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel;

class Ollama_Text_Generation_Model extends AbstractOpenAiCompatibleTextGenerationModel {

	protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
		return new Request( $method, Ollama_Provider::url( $path ), $headers, $data, $this->getRequestOptions() );
	}
}

That’s the entire class. The abstract parent already knows how to turn a WordPress Message array into an OpenAI-shaped messages payload, send it to chat/completions, and parse a choices[].message response back into a GenerativeAiResult, all verified directly from the SDK’s source in AbstractOpenAiCompatibleTextGenerationModel.php. The only thing it doesn’t know is where your endpoint actually lives, which is what createRequest() supplies.

Step 4: The model metadata directory

// File: my-plugin/includes/ollama-provider/class-ollama-model-metadata-directory.php
namespace MyPlugin\OllamaProvider;

use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory;

class Ollama_Model_Metadata_Directory extends AbstractOpenAiCompatibleModelMetadataDirectory {

	protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
		return new Request( $method, Ollama_Provider::url( $path ), $headers, $data );
	}

	protected function parseResponseToModelMetadataList( Response $response ): array {
		$data   = $response->getData();
		$models = array();

		if ( isset( $data['data'] ) && is_array( $data['data'] ) ) {
			foreach ( $data['data'] as $model_data ) {
				if ( isset( $model_data['id'] ) && is_string( $model_data['id'] ) ) {
					$models[] = new ModelMetadata(
						$model_data['id'],
						$model_data['id'],
						array( CapabilityEnum::textGeneration() ),
						array()
					);
				}
			}
		}

		return $models;
	}
}

GET /v1/models on Ollama, vLLM, and llama.cpp all return the same {"data": [{"id": "..."}, ...]} shape OpenAI’s own models endpoint returns, which is exactly what parseResponseToModelMetadataList() expects.

Step 5: Availability and the provider class

// File: my-plugin/includes/ollama-provider/class-ollama-provider-availability.php
namespace MyPlugin\OllamaProvider;

use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;

class Ollama_Provider_Availability implements ProviderAvailabilityInterface {

	public function isConfigured(): bool {
		// A local provider has no API key to check. Treat "configured" as
		// "the endpoint currently responds," a lightweight reachability probe.
		$response = wp_remote_get(
			Ollama_Provider::url( 'models' ),
			array( 'timeout' => 2 )
		);

		return ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response );
	}
}
// File: my-plugin/includes/ollama-provider/class-ollama-provider.php
namespace MyPlugin\OllamaProvider;

use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;

class Ollama_Provider extends AbstractApiProvider {

	protected static function baseUrl(): string {
		return defined( 'OLLAMA_BASE_URL' ) ? OLLAMA_BASE_URL : 'http://localhost:11434/v1';
	}

	protected static function createModel( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ): ModelInterface {
		return new Ollama_Text_Generation_Model( $modelMetadata, $providerMetadata );
	}

	protected static function createProviderMetadata(): ProviderMetadata {
		return new ProviderMetadata(
			'ollama-local',
			'Ollama (Local)',
			ProviderTypeEnum::server(),
			null,
			RequestAuthenticationMethod::apiKey()
		);
	}

	protected static function createProviderAvailability(): ProviderAvailabilityInterface {
		return new Ollama_Provider_Availability();
	}

	protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface {
		return new Ollama_Model_Metadata_Directory();
	}
}

ProviderTypeEnum::server() is the SDK’s own category for exactly this case, its source comment reads “self-hosted models,” distinct from cloud() (Anthropic, OpenAI, Google) and client() (browser-based models).

RequestAuthenticationMethod::apiKey() is mandatory, even for Ollama

The SDK currently implements exactly one authentication method, API-key. The abstract OpenAI-compatible model class always calls getRequestAuthentication() and attaches an Authorization: Bearer header before sending a request, there’s no code path that skips this. Declare apiKey() here regardless of whether your endpoint checks it, Ollama won’t, vLLM and llama.cpp will if you started them with --api-key.

Step 6: Register the provider and supply the API key

ProviderRegistry::registerProvider() reads the constant or environment variable matching your provider’s ID, converted to CONSTANT_CASE, automatically. For a provider ID of ollama-local and the apiKey property, that’s OLLAMA_LOCAL_API_KEY:

// File: wp-config.php
define( 'OLLAMA_LOCAL_API_KEY', 'not-checked-by-ollama-but-required-by-the-sdk' );

For vLLM or llama.cpp started with a real --api-key, put that actual key here instead.

// File: my-plugin.php
add_action(
	'plugins_loaded',
	function () {
		if ( ! class_exists( \WordPress\AiClient\AiClient::class ) ) {
			return;
		}

		\WordPress\AiClient\AiClient::defaultRegistry()->registerProvider(
			\MyPlugin\OllamaProvider\Ollama_Provider::class
		);
	}
);

This is the exact call an official provider plugin makes on activation. AiClient::defaultRegistry() is the same registry wp_ai_client_prompt() reads from internally, so once this runs, your local endpoint shows up as an ordinary option alongside Anthropic, OpenAI, and Google, including in using_model_preference() fallback chains from Course 5, Lesson 6.

Test it: confirm the registered provider actually answers

wp-env run cli wp eval '
$model  = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel( "ollama-local", "llama3.1" );
$result = \WordPress\AiClient\AiClient::generateTextResult( "Reply with exactly the word: connected", $model );
echo $result->getCandidates()[0]->getMessage()->getParts()[0]->getText();
'

A working setup prints connected, produced entirely by your local model, through the same SDK plumbing a cloud provider call goes through.

Recap

ProviderInterface, AbstractProvider, AbstractApiProvider, and the AbstractOpenAiCompatible* base classes are real, and building on the OpenAI-compatible ones means a custom provider for Ollama, vLLM, or llama.cpp is four small classes, not a from-scratch reimplementation. ProviderRegistry::registerProvider(), called through AiClient::defaultRegistry(), is the same registration path an official provider plugin uses, and it auto-wires an API key from an environment variable or wp-config.php constant named after your provider’s ID. Declare RequestAuthenticationMethod::apiKey() even for Ollama, the SDK always sends the header, whether or not the receiving end checks it.

Resources & further reading

← vLLM and llama.cpp as Alternatives Local RAG With a Self-Hosted Model →