Fine-Tuning

Serving a Fine-Tuned Model Back to WordPress

⏱ 19 min

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.

What you'll learn in this lesson
Serving your merged model with vLLM
The same OpenAI-compatible serving tool Course 24 already established, now pointed at a local model directory instead of a Hugging Face hub ID.
Reusing Course 24's exact provider pattern
AbstractApiProvider and AbstractOpenAiCompatibleTextGenerationModel, unchanged, pointed at a new base URL.
Registering the fine-tuned model as its own provider
ProviderRegistry::registerProvider(), the same call an official provider plugin makes.
Calling it through wp_ai_client_prompt() like any other model
The fine-tuned model becomes an ordinary option in a model preference list, not a special case.
Prerequisites

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.

A slow-starting endpoint looks like a broken one

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.

Resources & further reading

← Fine-Tuning Open-Weight Models Building Custom Embeddings for Better Semantic Search →