Files
prautoblogger/includes/admin/fields/class-open-router-model-field.php
peptiderepo 7401addbd9 v0.11.0 — OpenRouter model picker (#111)
v0.11.0 — OpenRouter model picker (AJAX refresh + cost preview + Phase-2 asset extract)

Replaces the free-text slug inputs for analysis_model, writing_model, and editor_model with a smart picker fed by a daily-refreshed OpenRouter model registry. Picker shows context size + input/output price + provider, filters by per-field capability, previews per-generation cost using existing cost-tracker history, and keeps a custom-slug fallback. Daily registry refresh fires from inside on_daily_generation() before lock acquire — no new top-level cron event.

Commit sequence (preserved for review):
- 353aa4e  feat(models): OpenRouter model picker + AJAX refresh + cost preview
- 283fa55  fix(phpcs): resolve 4 PHPCS violations in model-registry-refresh handler
- 1b1336a  style(phpcs): fix array syntax in model field + cost tracker
- 283f9f9  style(phpcs): fix tabs vs spaces in settings-page.php
- aa78591  refactor(assets): extract model-picker refresh handler to generic-handle file

Verification
- PHPCS strict (CI scope): 0 errors / 0 warnings
- PHPUnit: 206/206 (555 assertions, 2 pre-existing skips)
- All new files under 300-line cap (field renderer 163, AJAX handler 82)
- Security: AJAX nonce + manage_options cap; output escaped; input sanitized
- Phase 2 ready: zero PRAUTOBLOGGER_* coupling in registry/picker/AJAX classes; generic asset handle peptiderepo-model-picker

QA verdict: PASS (qa-reviews/prautoblogger/2026-04-23-aa78591.md)

Out of scope (untouched): cto-review.yml, prautoblogger_image_model field (Workers AI FLUX, Phase 3), Peptide Search AI / Peptide News fields (Phase 2).

Co-authored-by: Engineer PRAutoBlogger (in-session subagent) <engineer-prautoblogger@peptiderepo.users.noreply.github.com>
2026-04-23 12:11:59 +07:00

164 lines
6.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Renders the interactive model-picker field for OpenRouter models.
*
* 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),
* 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 {
/**
* Render the model picker trigger button and hidden input.
*
* @param string $id Field HTML id/name attribute.
* @param string $value Current model id (e.g. 'google/gemini-2.5-flash-lite').
* @param array<string, mixed> $args Field definition from settings fields.
*/
public static function render( string $id, string $value, array $args ): void {
$capability = $args['capability'] ?? 'text→text';
$display_name = '' !== $value ? $value : __( '— Select a model —', 'prautoblogger' );
$display_price = '';
if ( 'image_generation' === $capability ) {
// Resolve from the static image model list defined in settings.
$image_models = PRAutoBlogger_Settings_Fields_Extended::get_image_models();
foreach ( $image_models as $im ) {
if ( ( $im['id'] ?? '' ) === $value ) {
$display_name = $im['name'] ?? $value;
$display_price = '$' . number_format( (float) ( $im['cost_per_image'] ?? 0 ), 4 ) . '/image';
break;
}
}
} else {
// Resolve from the OpenRouter model registry cache.
$registry = prautoblogger()->get_executor()->get_model_registry();
$model = $registry->find_model( $value );
if ( null !== $model ) {
$display_name = $model['name'] ?? $value;
$in = (float) ( $model['input_price_per_m'] ?? 0 );
$out = (float) ( $model['output_price_per_m'] ?? 0 );
$display_price = self::format_price( $in ) . ' / ' . self::format_price( $out );
}
}
// Hidden input carries the actual value for the form submission.
printf(
'<input type="hidden" id="%s" name="%s" value="%s" />',
esc_attr( $id ),
esc_attr( $id ),
esc_attr( $value )
);
// Trigger button — opens the model picker popup via JS.
printf(
'<button type="button" class="ab-mp-trigger" data-field-id="%s" data-capability="%s">'
. '<span class="ab-mp-display-name">%s</span>'
. '<span class="ab-mp-display-price">%s</span>'
. '<span class="ab-mp-display-arrow">&#9662;</span>'
. '</button>',
esc_attr( $id ),
esc_attr( $capability ),
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 = array(
'prautoblogger_analysis_model' => array( 'analysis' ),
'prautoblogger_writing_model' => array( 'outline', 'draft', 'polish' ),
'prautoblogger_editor_model' => array( 'review' ),
);
return $map[ $field_id ] ?? array();
}
/**
* Format a price-per-million-tokens value for display.
*
* @param float $price Price per 1M tokens.
* @return string Formatted price string like "$0.25" or "Free".
*/
private static function format_price( float $price ): string {
if ( $price <= 0 ) {
return __( 'Free', 'prautoblogger' );
}
if ( $price < 0.01 ) {
return '$' . number_format( $price, 4 );
}
return '$' . number_format( $price, 2 );
}
}