diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100755 new mode 100644 index d3db336..0df268b --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.11.0] — 2026-04-23 + +### Added +- **OpenRouter model picker for text models.** Admin-facing dropdown field type (`model_select`) with searchable list, capability filtering, and estimated cost preview. Replaces free-text model slug inputs on the three text model settings (analysis, writing, editor). Includes daily cron refresh of the OpenRouter registry cache and manual "Refresh Model List" button on the AI Models tab. + +- **Cost preview in model picker.** Shows estimated cost per generation based on historical token usage for the 30 days prior. Maps each setting to its constituent pipeline stages (writing model = outline + draft + polish). + +- **AJAX refresh endpoint** (`prautoblogger_refresh_models`) for manual model registry refresh with nonce + capability gating (`manage_options`). + +- **New public method on Cost Tracker:** `get_avg_tokens_for_stages(array $stages, int $days = 30): array` returns average input/output token counts for a stage list, used by cost preview. + +### Changed +- **AI Models tab now renders a "Refresh Model List" button** next to the panel title. Triggers `prautoblogger_refresh_models` AJAX endpoint. + ## [0.10.1] — 2026-04-23 ### Fixed diff --git a/CONVENTIONS.md b/CONVENTIONS.md old mode 100755 new mode 100644 index 9a53e8c..9cb417f --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -234,6 +234,60 @@ Frontend components use `wp.element` (WordPress-bundled React) — no JSX, no bu 9. **No `echo` of raw data** — always escape with `esc_html()`, `esc_attr()`, etc. 10. **Nonce on every form/AJAX** — `wp_nonce_field()` / `check_ajax_referer()` + +## How To: Add an OpenRouter model picker field (v0.11.0+) + +Use the `model_select` field type to let admins choose OpenRouter models with a searchable dropdown, capability filtering, and estimated cost preview based on usage history. + +### 1. Declare the field in `class-settings-fields.php` + +```php +[ + 'id' => 'my_plugin_model', + 'label' => __( 'My Model Setting', 'my-plugin' ), + 'type' => 'model_select', + 'section' => 'my_plugin_settings', + 'default' => 'anthropic/claude-3.5-haiku', + 'capability' => 'text→text', // see ARCHITECTURE.md §18 for all capability strings + 'description' => __( 'Choose a model...', 'my-plugin' ), + 'badge' => __( 'Quality', 'my-plugin' ), +], +``` + +### 2. Map the setting to pipeline stages (for cost preview) + +If you want cost preview to work, add your setting ID and stage list to the `get_stages_for_setting()` method in `includes/admin/fields/class-open-router-model-field.php`: + +```php +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' ], + 'my_new_model_setting' => [ 'my_stage' ], // ADD HERE + ]; + return $map[ $field_id ] ?? []; +} +``` + +The cost preview queries `get_avg_tokens_for_stages()` on the Cost Tracker to show estimated cost per generation based on the last 30 days of usage. + +### 3. That's it + +The field renderer, AJAX refresh, model picker JS, and capability filtering are already wired. The admin will see a "Refresh Model List" button on the AI Models tab (or whichever tab contains the picker field). + +### Capability strings (locked in v0.11.0) + +- `text→text` — text input, text output (default for analysis/writing/editor) +- `text+image→text` — vision models +- `text+audio→text` — audio input models +- `text→image` — image generation +- `text→audio` — voice synthesis +- `text→video` — video generation +- `text→embedding` — embeddings + +Phase 3 will add provider selection; v0.11.0 is OpenRouter-only. + ## Git Workflow — PR-Gated, Soft-Enforced This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR. diff --git a/assets/js/admin.js b/assets/js/admin.js old mode 100755 new mode 100644 index a81a325..4b32e8c --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -446,4 +446,49 @@ }); }); + + // Refresh model registry button. + $(document).on('click', '#prautoblogger-refresh-models', function (e) { + e.preventDefault(); + var $btn = $(this); + var nonce = $btn.data('nonce'); + var originalText = $btn.html(); + + $btn.prop('disabled', true).addClass('ab-btn-loading'); + + $.ajax({ + url: ajaxurl, + method: 'POST', + data: { + action: 'prautoblogger_refresh_models', + nonce: nonce + }, + timeout: 30000 + }) + .done(function (response) { + if (response.success && response.data && response.data.message) { + // Show success toast + var $notice = $('
') + .html(' ' + response.data.message) + .insertBefore('.ab-layout') + .delay(4000) + .fadeOut(function () { + $(this).remove(); + }); + } + }) + .fail(function () { + var $notice = $('') + .html(' ' + 'Failed to refresh model list. Please try again.') + .insertBefore('.ab-layout') + .delay(5000) + .fadeOut(function () { + $(this).remove(); + }); + }) + .always(function () { + $btn.prop('disabled', false).removeClass('ab-btn-loading').html(originalText); + }); + }); + })(jQuery); diff --git a/includes/admin/fields/class-open-router-model-field.php b/includes/admin/fields/class-open-router-model-field.php index e4750a8..7b0aa5b 100644 --- a/includes/admin/fields/class-open-router-model-field.php +++ b/includes/admin/fields/class-open-router-model-field.php @@ -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( + '
%s
', + 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 '' . esc_html__( 'Estimated cost: — (insufficient history)', 'prautoblogger' ) . ''; + } + + $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( + '%s %s', + 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 ] ?? []; } /** diff --git a/includes/ajax/class-model-registry-refresh.php b/includes/ajax/class-model-registry-refresh.php new file mode 100644 index 0000000..36555ba --- /dev/null +++ b/includes/ajax/class-model-registry-refresh.php @@ -0,0 +1,82 @@ +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 ) + ), + ] + ); + } +} diff --git a/includes/class-ajax-handlers.php b/includes/class-ajax-handlers.php index c6987b3..b56fb53 100644 --- a/includes/class-ajax-handlers.php +++ b/includes/class-ajax-handlers.php @@ -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; + } } diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php index 5997cca..066f128 100755 --- a/includes/class-autoloader.php +++ b/includes/class-autoloader.php @@ -30,6 +30,7 @@ class PRAutoBlogger_Autoloader { 'models/', 'frontend/', 'services/', + 'ajax/', ); /** diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 0cbba6e..cdfc1b0 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -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' ) ); diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php old mode 100755 new mode 100644 index 4b0fe4c..337c098 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -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 $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 ), + ]; + } } diff --git a/prautoblogger.php b/prautoblogger.php index d214d98..0c21ad6 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.10.1 + * Version: 0.11.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.10.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.11.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/templates/admin/settings-page.php b/templates/admin/settings-page.php old mode 100755 new mode 100644 index 62fa2ce..17c1e30 --- a/templates/admin/settings-page.php +++ b/templates/admin/settings-page.php @@ -104,12 +104,18 @@ $next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
-
-

- -

- -
+
+

+ +

+ + + + +