*/ const DEFAULT_MODEL_PRICING = array( 'google/gemini-2.0-flash-001' => array( 'input' => 0.10, 'output' => 0.40 ), 'google/gemma-3-27b-it:free' => array( 'input' => 0.00, 'output' => 0.00 ), 'anthropic/claude-3.5-sonnet' => array( 'input' => 3.00, 'output' => 15.00 ), 'anthropic/claude-3-haiku' => array( 'input' => 0.25, 'output' => 1.25 ), 'openai/gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ), 'openai/gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ), ); /** @var string Budget enforcement mode: 'hard_stop', 'warn_only', or 'disabled'. */ const BUDGET_MODE_HARD_STOP = 'hard_stop'; const BUDGET_MODE_WARN_ONLY = 'warn_only'; const BUDGET_MODE_DISABLED = 'disabled'; /** * Create the llm_costs table. Called from Activator::activate(). * * Uses dbDelta() for idempotent CREATE/ALTER so it's safe to call on every * plugin upgrade without manual migration logic. */ public static function create_table(): void { global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL DEFAULT '', model VARCHAR(100) NOT NULL DEFAULT '', provider VARCHAR(50) NOT NULL DEFAULT 'openrouter', operation VARCHAR(50) NOT NULL DEFAULT '', prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0, completion_tokens INT UNSIGNED NOT NULL DEFAULT 0, total_tokens INT UNSIGNED NOT NULL DEFAULT 0, cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0.000000, article_id BIGINT(20) UNSIGNED DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_created_at (created_at), KEY idx_model (model), KEY idx_operation (operation), KEY idx_article (article_id) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * Log a single API call's token usage and cost. * * Called by LLM::call_openrouter() after a successful (or failed) API response. * Extracts token counts from the OpenRouter response's `usage` field and * calculates cost using model-specific pricing. * * @param string $model OpenRouter model ID (e.g., 'google/gemini-2.0-flash-001'). * @param string $operation What this call was for: 'keywords', 'summary', or 'filter'. * @param array $usage Token usage from API response: {prompt_tokens, completion_tokens, total_tokens}. * @param int|null $article_id Associated article ID, if applicable. * @param string $request_id OpenRouter request ID from response headers (for debugging). * @param float $explicit_cost Cost reported directly by the API (OpenRouter generation.total_cost), if available. * @return bool True on success. */ public static function log_api_call( string $model, string $operation, array $usage, ?int $article_id = null, string $request_id = '', float $explicit_cost = 0.0 ): bool { global $wpdb; $prompt_tokens = absint( $usage['prompt_tokens'] ?? 0 ); $completion_tokens = absint( $usage['completion_tokens'] ?? 0 ); $total_tokens = absint( $usage['total_tokens'] ?? ( $prompt_tokens + $completion_tokens ) ); // Use explicit cost from API if available, otherwise calculate from pricing table. $cost_usd = $explicit_cost > 0.0 ? $explicit_cost : self::calculate_cost( $model, $prompt_tokens, $completion_tokens ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $wpdb->insert( $wpdb->prefix . 'peptide_news_llm_costs', array( 'request_id' => sanitize_text_field( $request_id ), 'model' => sanitize_text_field( $model ), 'provider' => 'openrouter', 'operation' => sanitize_text_field( $operation ), 'prompt_tokens' => $prompt_tokens, 'completion_tokens' => $completion_tokens, 'total_tokens' => $total_tokens, 'cost_usd' => $cost_usd, 'article_id' => $article_id, 'created_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%f', '%d', '%s' ) ); if ( false === $result ) { Peptide_News_Logger::error( 'Failed to log API cost for model ' . $model, 'cost' ); return false; } // Check budget thresholds and fire alerts if needed. self::check_budget_alerts(); return true; } /** * Calculate estimated cost from token counts using model pricing. * * Pricing is per 1M tokens. Checks user-configured overrides first, * then falls back to DEFAULT_MODEL_PRICING, then to zero (free model assumed). * * @param string $model OpenRouter model ID. * @param int $prompt_tokens Number of input tokens. * @param int $completion_tokens Number of output tokens. * @return float Estimated cost in USD. */ public static function calculate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { $pricing = self::get_model_pricing( $model ); $input_cost = ( $prompt_tokens / 1000000.0 ) * $pricing['input']; $output_cost = ( $completion_tokens / 1000000.0 ) * $pricing['output']; return round( $input_cost + $output_cost, 6 ); } /** * Get pricing for a specific model (per 1M tokens). * * Checks user-configured custom pricing first, then falls back to defaults. * * @param string $model OpenRouter model ID. * @return array{input: float, output: float} */ public static function get_model_pricing( string $model ): array { // Check user-configured custom pricing overrides. $custom_pricing = get_option( 'peptide_news_custom_model_pricing', array() ); if ( is_array( $custom_pricing ) && isset( $custom_pricing[ $model ] ) ) { return array( 'input' => (float) ( $custom_pricing[ $model ]['input'] ?? 0.0 ), 'output' => (float) ( $custom_pricing[ $model ]['output'] ?? 0.0 ), ); } // Fall back to built-in defaults. if ( isset( self::DEFAULT_MODEL_PRICING[ $model ] ) ) { return self::DEFAULT_MODEL_PRICING[ $model ]; } // Unknown model — assume free to avoid overcharging estimates. return array( 'input' => 0.0, 'output' => 0.0 ); } /** * Check if the monthly budget has been exceeded. * * Should be called before every LLM API call to enforce the hard stop. * Returns true if budget mode is 'hard_stop' and current month's spend * meets or exceeds the configured limit. * * @return bool True if budget is exceeded and calls should be blocked. */ public static function is_budget_exceeded(): bool { $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { return false; } if ( self::BUDGET_MODE_HARD_STOP !== $mode ) { return false; } $current_spend = self::get_current_month_spend(); return $current_spend >= $limit; } /** * Get total spend for the current calendar month. * * Uses a short-lived transient cache (5 min) to avoid hammering the DB * on every budget check during batch processing. * * @return float Total USD spent this month. */ public static function get_current_month_spend(): float { $cache_key = 'peptide_news_month_spend_' . gmdate( 'Y_m' ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return (float) $cached; } global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $spend = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE created_at >= %s", gmdate( 'Y-m-01 00:00:00' ) ) ); set_transient( $cache_key, $spend, 5 * MINUTE_IN_SECONDS ); return $spend; } /** * Invalidate the monthly spend cache. * * Called after logging a new API call so the next budget check * reflects the latest spend. */ public static function invalidate_spend_cache(): void { delete_transient( 'peptide_news_month_spend_' . gmdate( 'Y_m' ) ); } /** * Check budget threshold alerts and log warnings. * * Fires at 50%, 80%, and 100% of the monthly budget. Each threshold * is only logged once per month (tracked via a transient). */ private static function check_budget_alerts(): void { $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { return; } // Invalidate cache so we get fresh numbers. self::invalidate_spend_cache(); $spend = self::get_current_month_spend(); $percent = ( $spend / $limit ) * 100.0; $alert_key = 'peptide_news_budget_alerts_' . gmdate( 'Y_m' ); $fired = get_transient( $alert_key ); if ( ! is_array( $fired ) ) { $fired = array(); } $thresholds = array( 50, 80, 100 ); foreach ( $thresholds as $threshold ) { if ( $percent >= $threshold && ! in_array( $threshold, $fired, true ) ) { $message = sprintf( 'LLM budget alert: %.0f%% of $%.2f monthly budget used ($%.4f spent).', $percent, $limit, $spend ); Peptide_News_Logger::warning( $message, 'cost' ); if ( 100 === $threshold && self::BUDGET_MODE_HARD_STOP === $mode ) { Peptide_News_Logger::error( 'Monthly LLM budget exceeded — API calls will be blocked until next month.', 'cost' ); } $fired[] = $threshold; } } set_transient( $alert_key, $fired, 35 * DAY_IN_SECONDS ); } /** * Get an aggregated cost summary for a date range. * * Returns total spend, total tokens, request count, and per-model breakdown. * Used by the admin cost dashboard. * * @param string $start_date Start date (Y-m-d format). * @param string $end_date End date (Y-m-d format). * @return array{total_cost: float, total_tokens: int, total_requests: int, by_model: array, by_operation: array} */ public static function get_cost_summary( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; // Overall totals. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $totals = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(SUM(total_tokens), 0) AS total_tokens, COUNT(*) AS total_requests FROM {$table} WHERE created_at >= %s AND created_at < %s", $start_date . ' 00:00:00', $end_date . ' 23:59:59' ) ); // Per-model breakdown. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $by_model = $wpdb->get_results( $wpdb->prepare( "SELECT model, COUNT(*) AS requests, COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, COALESCE(SUM(completion_tokens), 0) AS completion_tokens, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(cost_usd), 0) AS total_cost FROM {$table} WHERE created_at >= %s AND created_at < %s GROUP BY model ORDER BY total_cost DESC", $start_date . ' 00:00:00', $end_date . ' 23:59:59' ) ); // Per-operation breakdown. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $by_operation = $wpdb->get_results( $wpdb->prepare( "SELECT operation, COUNT(*) AS requests, COALESCE(SUM(total_tokens), 0) AS total_tokens, COALESCE(SUM(cost_usd), 0) AS total_cost FROM {$table} WHERE created_at >= %s AND created_at < %s GROUP BY operation ORDER BY total_cost DESC", $start_date . ' 00:00:00', $end_date . ' 23:59:59' ) ); return array( 'total_cost' => (float) ( $totals->total_cost ?? 0 ), 'total_tokens' => (int) ( $totals->total_tokens ?? 0 ), 'total_requests' => (int) ( $totals->total_requests ?? 0 ), 'by_model' => ! empty( $by_model ) ? $by_model : array(), 'by_operation' => ! empty( $by_operation ) ? $by_operation : array(), ); } /** * Get daily cost data for charting. * * Returns one row per day within the date range with aggregated cost and token counts. * * @param string $start_date Start date (Y-m-d). * @param string $end_date End date (Y-m-d). * @return array Array of objects with {date, cost, tokens, requests}. */ public static function get_daily_costs( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( $wpdb->prepare( "SELECT DATE(created_at) AS date, COALESCE(SUM(cost_usd), 0) AS cost, COALESCE(SUM(total_tokens), 0) AS tokens, COUNT(*) AS requests FROM {$table} WHERE created_at >= %s AND created_at < %s GROUP BY DATE(created_at) ORDER BY date ASC", $start_date . ' 00:00:00', $end_date . ' 23:59:59' ) ); return ! empty( $results ) ? $results : array(); } /** * Get the most recent API calls for the cost log table. * * @param int $limit Number of recent entries to return. * @return array Array of cost log rows. */ public static function get_recent_calls( int $limit = 50 ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) ); return ! empty( $results ) ? $results : array(); } /** * AJAX handler: return cost dashboard data. * * Accepts 'period' param: 'day', 'week', 'month', or 'custom' with start/end dates. * Returns summary stats and daily chart data. * * @since 2.4.0 */ public static function ajax_get_cost_data(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $period = sanitize_text_field( wp_unslash( $_GET['period'] ?? 'month' ) ); switch ( $period ) { case 'day': $start_date = gmdate( 'Y-m-d' ); $end_date = gmdate( 'Y-m-d' ); break; case 'week': $start_date = gmdate( 'Y-m-d', strtotime( '-6 days' ) ); $end_date = gmdate( 'Y-m-d' ); break; case 'custom': $raw_start = sanitize_text_field( wp_unslash( $_GET['start_date'] ?? '' ) ); $raw_end = sanitize_text_field( wp_unslash( $_GET['end_date'] ?? '' ) ); $start_date = self::validate_date( $raw_start ) ? $raw_start : gmdate( 'Y-m-01' ); $end_date = self::validate_date( $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); break; case 'month': default: $start_date = gmdate( 'Y-m-01' ); $end_date = gmdate( 'Y-m-d' ); break; } $summary = self::get_cost_summary( $start_date, $end_date ); $daily_costs = self::get_daily_costs( $start_date, $end_date ); $recent = self::get_recent_calls( 25 ); // Budget context. $budget_limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); $budget_mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); $month_spend = self::get_current_month_spend(); wp_send_json_success( array( 'summary' => $summary, 'daily_costs' => $daily_costs, 'recent_calls' => $recent, 'budget' => array( 'limit' => $budget_limit, 'mode' => $budget_mode, 'month_spend' => $month_spend, 'percent_used' => $budget_limit > 0 ? round( ( $month_spend / $budget_limit ) * 100, 1 ) : 0, ), 'period' => array( 'start' => $start_date, 'end' => $end_date, ), ) ); } /** * Validate a date string is in Y-m-d format and represents a real date. * * @param string $date Date string to validate. * @return bool True if valid Y-m-d date. */ private static function validate_date( string $date ): bool { $d = \DateTime::createFromFormat( 'Y-m-d', $date ); return $d && $d->format( 'Y-m-d' ) === $date; } /** * Prune old cost records beyond the configured retention period. * * Called on a monthly cron schedule. Default retention: 365 days. * * @return int Number of rows deleted. */ public static function prune_old_records(): int { $retention_days = absint( get_option( 'peptide_news_cost_retention', 365 ) ); if ( $retention_days < 30 ) { $retention_days = 30; // Minimum 30 days to prevent accidental data loss. } global $wpdb; $table = $wpdb->prefix . 'peptide_news_llm_costs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", gmdate( 'Y-m-d 00:00:00', strtotime( "-{$retention_days} days" ) ) ) ); if ( $deleted > 0 ) { Peptide_News_Logger::info( sprintf( 'Pruned %d cost records older than %d days.', $deleted, $retention_days ), 'cost' ); } return (int) $deleted; } }