feat(models): OpenRouter model picker + AJAX refresh + cost preview (v0.11.0 complete)

- AJAX endpoint 'prautoblogger_refresh_models' for manual model registry refresh
- Admin UI button on AI Models tab to trigger refresh (nonce + capability gated)
- Cost preview integration in field renderer using historical token averages
- New public method on Cost Tracker: get_avg_tokens_for_stages(array $stages, int $days = 30): array
- Enhanced field renderer with capability-specific cost calculation (outline+draft+polish for writing, single stage for analysis/editor)
- Autoloader updated to scan ajax/ directory for model registry refresh handler
- Admin JS enhanced with AJAX toggle handler for refresh button
- Template updated with refresh button + nonce in AI Models section
- CHANGELOG + CONVENTIONS + version bump to 0.11.0
- All files under 300-line cap, PHPCS-clean

Spec: https://github.com/peptiderepo/prautoblogger/issues/111
Open questions all resolved per seq-02 plan-ack.
This commit is contained in:
peptiderepo
2026-04-23 04:46:18 +00:00
parent e3e867353a
commit 353aa4eee4
11 changed files with 350 additions and 9 deletions

View File

@@ -8,13 +8,16 @@ declare(strict_types=1);
*
* What: a button showing the current model's name + pricing. Clicking it
* opens a JS-powered popup (model-picker.js) with a searchable list.
* Includes an estimated cost preview below based on historical usage.
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
* for fields with type 'model_select'.
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup).
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup),
* PRAutoBlogger_Cost_Tracker (historical token averages).
*
* @see admin/class-admin-page.php — Calls render() from the main field switch.
* @see assets/js/model-picker.js — JS popup that fetches + displays models.
* @see services/interface-model-registry.php — Registry interface for find_model().
* @see core/class-cost-tracker.php — get_avg_tokens_for_stages() for cost preview.
*/
class PRAutoBlogger_OpenRouter_Model_Field {
@@ -71,6 +74,75 @@ class PRAutoBlogger_OpenRouter_Model_Field {
esc_html( $display_name ),
esc_html( $display_price )
);
// Cost preview panel — only for text→text models (not image).
if ( 'image_generation' !== $capability && '' !== $value ) {
$cost_preview = self::get_cost_preview( $id, $value );
if ( $cost_preview ) {
printf(
'<div class="ab-mp-cost-preview">%s</div>',
wp_kses_post( $cost_preview )
);
}
}
}
/**
* Calculate and render the estimated cost preview for a model + setting combo.
*
* @param string $field_id Setting ID (e.g. 'prautoblogger_writing_model').
* @param string $model_id Model ID (e.g. 'anthropic/claude-3.5-haiku').
*
* @return string HTML snippet for the cost preview, or empty if no history.
*/
private static function get_cost_preview( string $field_id, string $model_id ): string {
$stages = self::get_stages_for_setting( $field_id );
if ( empty( $stages ) ) {
return '';
}
$registry = prautoblogger()->get_executor()->get_model_registry();
$model = $registry->find_model( $model_id );
if ( null === $model ) {
return '';
}
$tracker = new PRAutoBlogger_Cost_Tracker();
$tokens = $tracker->get_avg_tokens_for_stages( $stages, 30 );
if ( 0 === $tokens['sample_size'] ) {
return '<small>' . esc_html__( 'Estimated cost: — (insufficient history)', 'prautoblogger' ) . '</small>';
}
$in_price = (float) ( $model['input_price_per_m'] ?? 0 );
$out_price = (float) ( $model['output_price_per_m'] ?? 0 );
$avg_tokens_in = $tokens['avg_prompt_tokens'];
$avg_tokens_out = $tokens['avg_completion_tokens'];
// Cost per generation = (avg_in_tokens × in_price + avg_out_tokens × out_price) / 1_000_000
$cost_per_gen = ( ( $avg_tokens_in * $in_price ) + ( $avg_tokens_out * $out_price ) ) / 1_000_000;
return sprintf(
'<small>%s %s</small>',
esc_html__( 'Estimated cost per generation:', 'prautoblogger' ),
esc_html( '$' . number_format( $cost_per_gen, 4 ) )
);
}
/**
* Map a setting ID to its constituent pipeline stages.
*
* @param string $field_id Setting ID.
*
* @return string[] Stage names, e.g. ['outline', 'draft', 'polish'] for writing model.
*/
private static function get_stages_for_setting( string $field_id ): array {
$map = [
'prautoblogger_analysis_model' => [ 'analysis' ],
'prautoblogger_writing_model' => [ 'outline', 'draft', 'polish' ],
'prautoblogger_editor_model' => [ 'review' ],
];
return $map[ $field_id ] ?? [];
}
/**