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.
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.
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).
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
- PHP AI Client SDK repository, GitHub
- Introducing the AI Client in WordPress 7.0, make.wordpress.org
- Ollama OpenAI compatibility docs, docs.ollama.com