Cost

Model Routing for Cost

⏱ 16 min

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.

What you'll learn in this lesson
The scope of using_model_preference() (and its limit)
It picks the first available model in your order, it does not judge output quality or task complexity.
A complexity classifier that picks the preference list
A simple, explainable function, not a second model call just to decide which model to call.
Cheap-first with escalation on validation failure
Calling a cheap model, checking its output, and only re-calling a stronger model when the check fails.
Logging routing decisions alongside the cost log from the previous lesson
So you can see whether cheap-first is actually saving money in practice.
Prerequisites

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.

Keep the high-stakes task list explicit and reviewed

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.

Escalation doubles the cost of the tasks that trigger it

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

← Building a Cost-Monitoring Dashboard Async and Background Processing With Action Scheduler →