fix(llm): add 429 retry, timeout guard, model validation, XSS protection, batch cache clear
This commit is contained in:
@@ -3,8 +3,7 @@
|
||||
* LLM integration via OpenRouter for article analysis.
|
||||
*
|
||||
* Provides keyword extraction and article summarization
|
||||
|
||||
aO
|
||||
* using configurable models through the OpenRouter API.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@@ -13,6 +12,12 @@ class Peptide_News_LLM {
|
||||
/** @var string OpenRouter API endpoint. */
|
||||
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
/** @var int Maximum retries for rate-limited requests. */
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/** @var int Maximum elapsed seconds before stopping a batch. */
|
||||
const BATCH_TIMEOUT = 120;
|
||||
|
||||
/**
|
||||
* Check whether LLM processing is enabled and configured.
|
||||
*
|
||||
@@ -20,7 +25,19 @@ class Peptide_News_LLM {
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an OpenRouter model ID format.
|
||||
*
|
||||
* Accepts patterns like: provider/model-name, provider/model-name:version
|
||||
*
|
||||
* @param string $model
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid_model( $model ) {
|
||||
return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,50 +47,61 @@ class Peptide_News_LLM {
|
||||
*
|
||||
* @param object $article Database row with id, title, excerpt, content, categories.
|
||||
* @param bool $force Re-process even if already analyzed.
|
||||
* @param bool $force Re-process even if already analyzed.
|
||||
* @return array Results with 'keywords', 'summary', and 'success' keys.
|
||||
*/
|
||||
public static function process_article( $article, $force = false ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return array( 'keywords' => '', 'summary' => '', 'success' => false );
|
||||
}
|
||||
|
||||
$results = array(
|
||||
'keywords' => '',
|
||||
'summary' => '',
|
||||
'success' => false,
|
||||
);
|
||||
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$any_success = false;
|
||||
|
||||
// --- Keyword extraction ---
|
||||
$keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
|
||||
$need_keywords = $force || empty( $article->tags );
|
||||
|
||||
|
||||
if ( $need_keywords && self::is_valid_model( $keywords_model ) ) {
|
||||
$keywords_prompt = self::build_keywords_prompt( $article );
|
||||
$keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt );
|
||||
|
||||
if ( ! is_wp_error( $keywords_result ) ) {
|
||||
$results['keywords'] = self::sanitize_keywords( $keywords_result );
|
||||
$any_success = true;
|
||||
} else {
|
||||
self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() );
|
||||
}
|
||||
} elseif ( $need_keywords ) {
|
||||
self::log_error( 'Invalid keywords model ID: ' . $keywords_model );
|
||||
}
|
||||
|
||||
// --- Summarization ---
|
||||
$summary_model = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' );
|
||||
$need_summary = $force || empty( $article->ai_summary );
|
||||
|
||||
|
||||
if ( $need_summary && self::is_valid_model( $summary_model ) ) {
|
||||
$summary_prompt = self::build_summary_prompt( $article );
|
||||
$summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt );
|
||||
|
||||
if ( ! is_wp_error( $summary_result ) ) {
|
||||
if ( ! is_wp_error( $summary_result ) ) {
|
||||
// Strip all HTML tags from LLM output to prevent XSS.
|
||||
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
|
||||
$any_success = true;
|
||||
} else {
|
||||
self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() );
|
||||
}
|
||||
} elseif ( $need_summary ) {
|
||||
self::log_error( 'Invalid summary model ID: ' . $summary_model );
|
||||
}
|
||||
|
||||
$results['success'] = $any_success;
|
||||
|
||||
// --- Persist results ---
|
||||
if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) {
|
||||
self::save_results( $article->id, $results, $force );
|
||||
@@ -85,16 +113,26 @@ class Peptide_News_LLM {
|
||||
/**
|
||||
* Process all unanalyzed articles (called after a fetch cycle).
|
||||
*
|
||||
*
|
||||
* @param int $batch_size Max articles to process per run (avoids timeout).
|
||||
* Includes a time-based guard to prevent cron timeouts and
|
||||
* respects the admin-configured max articles per cycle.
|
||||
*
|
||||
* @param int $batch_size Max articles to process per run.
|
||||
* @return int Number of articles successfully processed.
|
||||
*/
|
||||
public static function process_unanalyzed( $batch_size = 10 ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Respect admin cost-control setting.
|
||||
$max_per_cycle = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
|
||||
if ( $max_per_cycle < 1 ) {
|
||||
$max_per_cycle = 10;
|
||||
}
|
||||
$batch_size = min( $batch_size, $max_per_cycle );
|
||||
|
||||
global $wpdb;
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
// Find articles missing tags OR ai_summary.
|
||||
$articles = $wpdb->get_results( $wpdb->prepare(
|
||||
@@ -108,20 +146,38 @@ class Peptide_News_LLM {
|
||||
) );
|
||||
|
||||
$processed = 0;
|
||||
$start_time = time();
|
||||
|
||||
foreach ( $articles as $article ) {
|
||||
foreach ( $articles as $article ) {
|
||||
self::process_article( $article );
|
||||
// Timeout guard: stop if approaching the limit.
|
||||
if ( ( time() - $start_time ) >= self::BATCH_TIMEOUT ) {
|
||||
self::log_error( 'Batch timeout reached after ' . $processed . ' articles. Remaining will process next cycle.' );
|
||||
break;
|
||||
}
|
||||
|
||||
$result = self::process_article( $article );
|
||||
|
||||
// Only count successful processing.
|
||||
if ( ! empty( $result['success'] ) ) {
|
||||
$processed++;
|
||||
}
|
||||
|
||||
// Small delay to avoid rate limiting.
|
||||
// Small delay to avoid rate limiting.
|
||||
if ( $processed < count( $articles ) ) {
|
||||
if ( next( $articles ) !== false ) {
|
||||
usleep( 500000 ); // 0.5s between articles.
|
||||
}
|
||||
}
|
||||
|
||||
// Clear article cache once per batch (not per article).
|
||||
if ( $processed > 0 ) {
|
||||
self::clear_article_cache();
|
||||
}
|
||||
|
||||
// Log the processing result.
|
||||
update_option( 'peptide_news_last_llm_process', array(
|
||||
'time' => current_time( 'mysql' ),
|
||||
'processed' => $processed,
|
||||
'attempted' => count( $articles ),
|
||||
) );
|
||||
|
||||
return $processed;
|
||||
@@ -170,7 +226,7 @@ class Peptide_News_LLM {
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Call the OpenRouter API with retry logic for 429 rate limits.
|
||||
*
|
||||
* @param string $api_key
|
||||
* @param string $model
|
||||
@@ -190,46 +246,80 @@ class Peptide_News_LLM {
|
||||
'temperature' => 0.3,
|
||||
);
|
||||
|
||||
|
||||
$response = wp_remote_post( self::API_URL, array(
|
||||
'timeout' => 60,
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'Peptide News Aggregator',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
$retries = 0;
|
||||
$backoff = 2; // Initial backoff in seconds.
|
||||
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
while ( $retries <= self::MAX_RETRIES ) {
|
||||
$response = wp_remote_post( self::API_URL, array(
|
||||
'timeout' => 30,
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'Peptide News Aggregator',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
// Handle rate limiting with exponential backoff.
|
||||
if ( 429 === $status && $retries < self::MAX_RETRIES ) {
|
||||
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
|
||||
$wait = $retry_after ? min( (int) $retry_after, 30 ) : $backoff;
|
||||
sleep( $wait );
|
||||
$retries++;
|
||||
$backoff *= 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( $status < 200 || $status >= 300 ) {
|
||||
if ( $status < 200 || $status >= 300 ) {
|
||||
$error_msg = 'HTTP ' . $status;
|
||||
if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) {
|
||||
$error_msg = $data['error']['message'];
|
||||
}
|
||||
return new WP_Error( 'openrouter_error', $error_msg );
|
||||
}
|
||||
|
||||
|
||||
if ( empty( $data['choices'][0]['message']['content'] ) ) {
|
||||
// Validate the response structure thoroughly.
|
||||
if ( ! is_array( $data ) ) {
|
||||
return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' );
|
||||
}
|
||||
if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' );
|
||||
}
|
||||
if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' );
|
||||
}
|
||||
if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' );
|
||||
}
|
||||
|
||||
|
||||
return trim( $data['choices'][0]['message']['content'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and normalize keyword output from the LLM.
|
||||
*
|
||||
*
|
||||
* @param string $raw Raw comma-separated keywords.
|
||||
* @return string Cleaned, deduplicated, comma-separated list.
|
||||
*/
|
||||
private static function sanitize_keywords( $raw ) {
|
||||
// Strip all HTML first to prevent XSS.
|
||||
$raw = wp_strip_all_tags( $raw );
|
||||
|
||||
// Remove any numbering, bullets, or extra formatting.
|
||||
$raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw );
|
||||
$raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw );
|
||||
$raw = preg_replace( '/^[-*\x{2022}]\s*/mu', '', $raw );
|
||||
|
||||
$keywords = array_map( 'trim', explode( ',', $raw ) );
|
||||
$keywords = array_map( 'sanitize_text_field', $keywords );
|
||||
@@ -245,6 +335,9 @@ class Peptide_News_LLM {
|
||||
/**
|
||||
* Save LLM results to the database.
|
||||
*
|
||||
* Note: Cache is NOT cleared here — the caller (process_unanalyzed)
|
||||
* clears cache once per batch for efficiency.
|
||||
*
|
||||
* @param int $article_id
|
||||
* @param array $results Array with 'keywords' and 'summary'.
|
||||
* @param bool $force Overwrite existing values.
|
||||
@@ -274,9 +367,6 @@ class Peptide_News_LLM {
|
||||
$format,
|
||||
array( '%d' )
|
||||
);
|
||||
);
|
||||
|
||||
// Clear the article cache so fresh data shows on the front end.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user