Lesson 3 produced a real, merged model directory sitting on disk. This lesson is where
fine-tuning stops being a Python exercise and becomes a WordPress feature. The path back
in is not new: it’s exactly the ProviderInterface, AbstractProvider, and
ProviderRegistry pattern Course 24
established for self-hosted Ollama and vLLM endpoints. A fine-tuned model served behind
an OpenAI-compatible endpoint plugs into that same pattern unchanged, since from the PHP
AI Client SDK’s point of view, “a self-hosted model” and “a self-hosted, fine-tuned
model” look identical, they’re both an HTTP endpoint speaking the same request shape.
Lesson 3’s merged model directory (./site-model-merged), and Course 24 Lesson 4,
“Building a Custom Provider for the PHP AI Client SDK,” completed. This lesson reuses
that lesson’s classes directly and only changes what they point at, it doesn’t
re-explain ProviderInterface from scratch.
Step 1: Serve the merged model with vLLM
vLLM, the same self-hosted serving tool Course 24 used for a stock open-weight model, serves a fine-tuned model exactly the same way, since it takes a model directory, not specifically a Hugging Face hub identifier:
# File: terminal, on the GPU machine serving the model
pip install vllm
vllm serve ./site-model-merged \
--served-model-name site-support-v1 \
--api-key your-real-secret-key \
--port 8000
--served-model-name is the identifier WordPress will request later, it can be
anything descriptive, it doesn’t need to match the directory name. This produces the
same /v1/chat/completions OpenAI-compatible endpoint Course 24’s Ollama and vLLM
lessons already relied on, the only difference is the model behind it was trained on
your own content.
Step 2: Reuse Course 24’s provider classes, change only the base URL
Course 24 built four small classes for a self-hosted provider: a model class extending
AbstractOpenAiCompatibleTextGenerationModel, a model metadata directory extending
AbstractOpenAiCompatibleModelMetadataDirectory, an availability checker implementing
ProviderAvailabilityInterface, and a provider class extending AbstractApiProvider.
None of that changes here. Only the base URL and the provider’s identity change:
// File: my-plugin/includes/site-model-provider/class-site-model-provider.php
namespace MyPlugin\SiteModelProvider;
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 Site_Model_Provider extends AbstractApiProvider {
protected static function baseUrl(): string {
return defined( 'SITE_MODEL_BASE_URL' ) ? SITE_MODEL_BASE_URL : 'http://localhost:8000/v1';
}
protected static function createModel( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ): ModelInterface {
return new Site_Model_Text_Generation_Model( $modelMetadata, $providerMetadata );
}
protected static function createProviderMetadata(): ProviderMetadata {
return new ProviderMetadata(
'site-model',
'Site Fine-Tuned Model',
ProviderTypeEnum::server(),
null,
RequestAuthenticationMethod::apiKey()
);
}
protected static function createProviderAvailability(): ProviderAvailabilityInterface {
return new Site_Model_Provider_Availability();
}
protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface {
return new Site_Model_Metadata_Directory();
}
}
The model class itself is the identical pattern Course 24 wrote for Ollama, just
renamed and pointed at Site_Model_Provider::url():
// File: my-plugin/includes/site-model-provider/class-site-model-text-generation-model.php
namespace MyPlugin\SiteModelProvider;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel;
class Site_Model_Text_Generation_Model extends AbstractOpenAiCompatibleTextGenerationModel {
protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
return new Request( $method, Site_Model_Provider::url( $path ), $headers, $data, $this->getRequestOptions() );
}
}
The availability checker and metadata directory classes are the exact same shape
Course 24 Lesson 4 wrote for Ollama, a reachability probe against /v1/models and a
parser for the same {"data": [{"id": "..."}]} response shape vLLM returns. Nothing
about a fine-tuned model changes that response format, it’s still vLLM serving an
OpenAI-compatible API, so those two classes carry over completely unmodified aside from
their class names.
Step 3: Set the API key and register the provider
// File: wp-config.php
define( 'SITE_MODEL_API_KEY', 'your-real-secret-key' ); // Must match vllm serve --api-key.
define( 'SITE_MODEL_BASE_URL', 'http://localhost:8000/v1' );
// File: my-plugin.php
add_action(
'plugins_loaded',
function () {
if ( ! class_exists( \WordPress\AiClient\AiClient::class ) ) {
return;
}
\WordPress\AiClient\AiClient::defaultRegistry()->registerProvider(
\MyPlugin\SiteModelProvider\Site_Model_Provider::class
);
}
);
ProviderRegistry::registerProvider() reads SITE_MODEL_API_KEY automatically, the
constant name derived from the provider ID site-model converted to CONSTANT_CASE
with _API_KEY appended, exactly the same auto-wiring Course 24 confirmed for Ollama.
Step 4: Call it exactly like any other model
Once registered, the fine-tuned model is an ordinary entry in a preference list, no different from Anthropic, OpenAI, or an unmodified self-hosted model:
// File: my-plugin/includes/class-support-reply.php
$model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel( 'site-model', 'site-support-v1' );
$result = \WordPress\AiClient\AiClient::generateTextResult( $support_question, $model );
echo $result->getCandidates()[0]->getMessage()->getParts()[0]->getText();
This is the entire payoff of reusing Course 24’s pattern rather than inventing a
one-off integration for fine-tuned models specifically: the rest of your codebase,
wp_ai_client_prompt() calls, fallback chains, provider preference lists, doesn’t know
or care that this particular model was fine-tuned on your own content. It’s just another
registered provider.
Test it: confirm the fine-tuned model actually responds in its adapted style
wp-env run cli wp eval '
$model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel( "site-model", "site-support-v1" );
$result = \WordPress\AiClient\AiClient::generateTextResult( "Write a product description for a wireless keyboard.", $model );
echo $result->getCandidates()[0]->getMessage()->getParts()[0]->getText();
'
You should see output that reads in your fine-tuned style, generated through the exact same SDK call path as any other provider, not a special one-off HTTP call bypassing the registry.
vLLM takes real time, often a minute or more for an 8B-class model, to load weights and
report itself healthy after vllm serve starts. If your availability checker or a
request fires during that startup window, it’ll report unavailable or time out, which
reads as a broken integration when it’s actually just an endpoint still loading. Confirm
vllm serve has finished its startup logging before testing, and consider a longer
timeout in your availability probe than Course 24’s original 2-second Ollama example
used, a fine-tuned model server under real load needs more headroom.
Recap
Nothing about serving a fine-tuned model back to WordPress requires new SDK concepts.
vLLM serves the Lesson 3 model directory behind the same OpenAI-compatible endpoint
shape Course 24 already established, and the same four provider classes, AbstractApiProvider,
AbstractOpenAiCompatibleTextGenerationModel, an availability checker, and a metadata
directory, register it through ProviderRegistry::registerProvider() exactly as Course
24’s Ollama example did. From that point on, wp_ai_client_prompt() and the rest of
your codebase treat the fine-tuned model as an ordinary provider option, not a special
case.