using_model_preference() resolves to the first model in your list with a working,
configured provider behind it. That’s an availability fallback, it reacts to a model
being unreachable, not to a model being the wrong tier for the job. Cost-based routing,
sending easy tasks to a cheaper, faster model and only paying for a stronger one when a
task actually needs it, is a decision your own code has to make before it ever calls
using_model_preference(). This lesson builds that decision layer: a complexity check
that picks between preference lists, and an escalation pattern for when a cheap model’s
output isn’t good enough and a second, stronger call is worth the extra cost.
Course 4’s provider-switching lesson (using_model_preference() basics), and the usage
logging from the previous lesson in this course, since this lesson’s routing decisions
feed the same table.
Step 1: What using_model_preference() will and won’t do for you
It’s worth being precise about this, since it’s easy to assume the SDK is doing more than it is. Given an ordered list, it tries each entry in order and uses the first one whose provider is actually configured and available. It does not look at your prompt, estimate difficulty, or downgrade to a cheaper model because the task looked simple. Routing by cost or complexity is work you do before the call, by choosing which preference list to hand it, not something the method decides for you.
// File: my-plugin/class-model-router.php
const CHEAP_FIRST = array( 'gemini-3-pro-preview', 'claude-sonnet-4-5', 'gpt-5.1' );
const STRONG_FIRST = array( 'claude-sonnet-4-5', 'gpt-5.1', 'gemini-3-pro-preview' );
Two named lists, ordered differently on purpose. Which one a given call uses is the actual routing decision, made in Step 2.
Step 2: A complexity check simple enough to trust
The temptation is to use a model to decide which model to use, which just moves the cost problem one call earlier instead of solving it. A cheaper, deterministic check on your own input, task type, input length, whether structured output is required, gets you most of the value without an extra billed call:
// File: my-plugin/class-model-router.php
function my_plugin_pick_model_preference( string $task_type, string $input_text ): array {
$long_input = strlen( $input_text ) > 4000;
$high_stakes_task = in_array( $task_type, array( 'legal-summary', 'medical-summary', 'refund-decision' ), true );
if ( $high_stakes_task || $long_input ) {
return STRONG_FIRST;
}
return CHEAP_FIRST;
}
This is deliberately simple: a short list of task types that always warrant the stronger tier, and a length threshold as a rough proxy for how much the model actually has to reason about. It won’t be perfect for every case, but it’s fast, free, and explainable, which matters more than perfect classification for most features.
Don’t try to infer “this task is high-stakes” from the prompt text itself with string matching, that’s fragile and easy to defeat by accident. Maintain an explicit list of task type identifiers your own code assigns, reviewed the same way you’d review a permission list, since this decision directly affects both cost and output quality.
Step 3: Escalate on validation failure, not on a hunch
For tasks where a cheap model’s output can be mechanically checked, structured JSON matching a schema, a required field being present, a length constraint, cheap-first with escalation is the highest-value pattern in this lesson: call the cheap model, validate what came back, and only pay for the stronger model if validation actually fails.
// File: my-plugin/class-model-router.php
function my_plugin_generate_with_escalation( string $feature, string $prompt, string $system_instruction, array $schema ) {
$cheap_result = wp_ai_client_prompt( $prompt )
->using_system_instruction( $system_instruction )
->using_model_preference( 'gemini-3-pro-preview' )
->as_json_response( $schema )
->generate_text();
$decoded = is_wp_error( $cheap_result ) ? null : json_decode( $cheap_result, true );
if ( is_array( $decoded ) && my_plugin_validate_against_schema( $decoded, $schema ) ) {
my_plugin_log_ai_usage( $feature . '-cheap', 'gemini-3-pro-preview', $prompt, $cheap_result );
return $decoded;
}
// Cheap model's output didn't validate, escalate to a stronger model.
$strong_result = wp_ai_client_prompt( $prompt )
->using_system_instruction( $system_instruction )
->using_model_preference( 'claude-sonnet-4-5' )
->as_json_response( $schema )
->generate_text();
if ( is_wp_error( $strong_result ) ) {
return $strong_result;
}
my_plugin_log_ai_usage( $feature . '-escalated', 'claude-sonnet-4-5', $prompt, $strong_result );
return json_decode( $strong_result, true );
}
my_plugin_validate_against_schema() is your own check, in the simplest case just
confirming json_decode() succeeded and the required keys from the schema are present,
in a stricter case running an actual JSON Schema validator. Either way, the escalation
only fires when the cheap attempt demonstrably didn’t produce usable output, not on a
guess about whether it might be good enough.
Cheap-first with escalation only saves money on average if most calls pass validation
on the first, cheap attempt. If a task type escalates most of the time, you’re paying
for both calls on most requests, worse than just calling the strong model directly.
Track the -cheap versus -escalated feature labels in the usage log from the
previous lesson and watch the escalation rate per task type; a high rate is a sign that
task type belongs in STRONG_FIRST outright, not in this pattern.
Step 4: Read the routing decision back out of the cost log
Because both branches log through my_plugin_log_ai_usage() with distinct feature
suffixes, the dashboard from the previous lesson already shows whether cheap-first is
paying off, no new tooling required:
// File: my-plugin/class-model-router.php
global $wpdb;
$table = $wpdb->prefix . 'ai_usage_log';
$escalation_rate = $wpdb->get_row(
"SELECT
SUM(CASE WHEN feature LIKE '%-escalated' THEN 1 ELSE 0 END) AS escalated,
SUM(CASE WHEN feature LIKE '%-cheap' OR feature LIKE '%-escalated' THEN 1 ELSE 0 END) AS total
FROM {$table}
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
);
If escalated is a small fraction of total, cheap-first is working as intended. If
it’s climbing toward half or more, that task type is a candidate for STRONG_FIRST.
Test it
Run the escalation function against a task deliberately shaped to fail cheap-model validation (an overly strict schema is an easy way to force it), and confirm both a cheap attempt and an escalated attempt get logged:
wp-env run cli wp eval '
$schema = array(
"type" => "object",
"properties" => array( "summary" => array( "type" => "string" ), "risk_level" => array( "type" => "string" ) ),
"required" => array( "summary", "risk_level" ),
);
$result = my_plugin_generate_with_escalation( "ticket-triage", "Summarize this support ticket and rate its risk level.", "Return the requested JSON only.", $schema );
var_dump( $result );
'
Then query the usage log for feature LIKE 'ticket-triage%' and confirm you see
either one -cheap row (validation passed) or one -cheap row plus one -escalated
row (validation failed and it escalated).
Recap
using_model_preference() is an availability fallback, not a complexity-aware router,
so the actual routing decision, which preference list to hand it, belongs in your own
code. A simple, explicit complexity check picking between a cheap-first and a
strong-first list covers most cases cheaply. Where output can be mechanically
validated, cheap-first with escalation on validation failure is the strongest pattern
here, but only pays off if escalation stays rare, which is exactly what the usage log
from the previous lesson lets you verify instead of assume.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- Introducing the WordPress AI Client SDK, make.wordpress.org