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:
@@ -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 ] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
82
includes/ajax/class-model-registry-refresh.php
Normal file
82
includes/ajax/class-model-registry-refresh.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AJAX handler to manually refresh the OpenRouter model registry.
|
||||
*
|
||||
* What: Allows admins to trigger an immediate refresh of the cached model
|
||||
* list via a button in the admin UI, without waiting for the daily
|
||||
* cron. Useful for testing new models or after adding an API key.
|
||||
* Who calls: Admin-facing JavaScript (model-picker.js) via wp_ajax.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Model_Registry, capability gating.
|
||||
*
|
||||
* Triggered by: AJAX POST to 'wp-admin/admin-ajax.php?action=prautoblogger_refresh_models'
|
||||
* Side effects: HTTP GET to OpenRouter /api/v1/models (optional, if 12h window passed);
|
||||
* wp_options write; transient set.
|
||||
*
|
||||
* @see includes/class-ajax-handlers.php — Instantiation and wiring.
|
||||
* @see assets/js/model-picker.js — Client-side trigger.
|
||||
* @see services/class-open-router-model-registry.php — Registry that performs the work.
|
||||
*/
|
||||
class PRAutoBlogger_Model_Registry_Refresh {
|
||||
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry Lazy-loaded singleton. */
|
||||
private PRAutoBlogger_OpenRouter_Model_Registry $registry;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_OpenRouter_Model_Registry $registry Shared model registry.
|
||||
*/
|
||||
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $registry ) {
|
||||
$this->registry = $registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the AJAX refresh request.
|
||||
*
|
||||
* Security: nonce-gated via wp-nonce, capability gated to manage_options.
|
||||
* Response format: JSON { success: bool, data: { count: int, fetched_at: int, message: string } }
|
||||
*
|
||||
* @side-effect Writes HTTP GET request, database options, transient cache.
|
||||
* @return void (echoes JSON)
|
||||
*/
|
||||
public function handle(): void {
|
||||
// Nonce check.
|
||||
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'prautoblogger_refresh_models' ) ) {
|
||||
wp_send_json_error(
|
||||
[
|
||||
'message' => __( 'Security check failed. Please refresh the page and try again.', 'prautoblogger' ),
|
||||
],
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// Capability check.
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error(
|
||||
[
|
||||
'message' => __( 'Insufficient permissions. Only administrators can refresh the model list.', 'prautoblogger' ),
|
||||
],
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// Perform the refresh (force=true bypasses the idempotency window).
|
||||
$result = $this->registry->refresh( true );
|
||||
|
||||
$count = (int) ( $result['count'] ?? 0 );
|
||||
$fetched_at = (int) ( $result['fetched_at'] ?? 0 );
|
||||
$mins_ago = $fetched_at > 0 ? intval( ( time() - $fetched_at ) / 60 ) : 0;
|
||||
|
||||
wp_send_json_success(
|
||||
[
|
||||
'count' => $count,
|
||||
'fetched_at' => $fetched_at,
|
||||
'message' => sprintf(
|
||||
__( 'Model registry refreshed: %d models loaded, last updated %s', 'prautoblogger' ),
|
||||
$count,
|
||||
0 === $mins_ago ? __( 'just now', 'prautoblogger' ) : sprintf( __( '%d minutes ago', 'prautoblogger' ), $mins_ago )
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -150,4 +150,13 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
}
|
||||
wp_send_json_success( $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shared model registry instance.
|
||||
*
|
||||
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
||||
*/
|
||||
public function get_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||
return $this->model_registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class PRAutoBlogger_Autoloader {
|
||||
'models/',
|
||||
'frontend/',
|
||||
'services/',
|
||||
'ajax/',
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -153,6 +153,7 @@ class PRAutoBlogger {
|
||||
add_action( 'wp_ajax_prautoblogger_generate_image', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
|
||||
|
||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
|
||||
|
||||
57
includes/core/class-cost-tracker.php
Executable file → Normal file
57
includes/core/class-cost-tracker.php
Executable file → Normal file
@@ -210,4 +210,61 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
public function get_current_run_cost(): float {
|
||||
return $this->current_run_cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average input/output token counts for given stages over a time period.
|
||||
*
|
||||
* Used by the model picker field renderer to calculate estimated costs
|
||||
* per generation based on historical token usage. Maps setting IDs to their
|
||||
* constituting stages (e.g., writing model controls outline + draft + polish).
|
||||
*
|
||||
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||
* @param int $days Historical window (default 30 days).
|
||||
*
|
||||
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
|
||||
* Returns empty counters if no history. Never throws.
|
||||
*/
|
||||
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( empty( $stages ) ) {
|
||||
return [
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$cutoff_time = time() - ( $days * DAY_IN_SECONDS );
|
||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT AVG(input_tokens) as avg_input,
|
||||
AVG(output_tokens) as avg_output,
|
||||
COUNT(*) as sample_count
|
||||
FROM $table_name
|
||||
WHERE stage IN ( $stage_list )
|
||||
AND timestamp >= %d",
|
||||
$cutoff_time
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||
$result = $wpdb->get_row( $query, ARRAY_A );
|
||||
|
||||
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
|
||||
return [
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
|
||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user