diff --git a/includes/class-psa-cost-reporter.php b/includes/class-psa-cost-reporter.php new file mode 100644 index 0000000..148eab1 --- /dev/null +++ b/includes/class-psa-cost-reporter.php @@ -0,0 +1,136 @@ +prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end + ) + ); + + return floatval( $result ); + } + + /** + * Get total token usage for a given month. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return int Total tokens. + */ + public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { + global $wpdb; + list( $start, $end ) = self::month_range( $year, $month ); + $table = $wpdb->prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end + ) + ); + + return (int) $result; + } + + /** + * Count API calls this month that used character-based token estimates. + * + * Why: Lets the admin UI flag when reported costs include approximations + * so operators can distinguish measured spend from estimated spend. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return int Number of estimated rows. + */ + public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { + global $wpdb; + list( $start, $end ) = self::month_range( $year, $month ); + $table = $wpdb->prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end, + 'estimated' + ) + ); + + return (int) $result; + } + + /** + * Get the most recent API call logs. + * + * @param int $limit Maximum number of logs to return. + * @return array Array of log row objects. + */ + public static function get_recent_logs( int $limit = 20 ): array { + global $wpdb; + $table = $wpdb->prefix . 'psa_api_logs'; + $limit = max( 1, (int) $limit ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM ' . $table . ' ORDER BY created_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $limit + ) + ); + + return is_array( $results ) ? $results : array(); + } + + /** + * Build start/end datetime strings for a given month. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return array{0: string, 1: string} [start_date, end_datetime] + */ + private static function month_range( ?int $year, ?int $month ): array { + $year = $year ?? (int) gmdate( 'Y' ); + $month = max( 1, min( 12, $month ?? (int) gmdate( 'm' ) ) ); + + $start = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) ); + $end = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59'; + + return array( $start, $end ); + } +} diff --git a/includes/class-psa-cost-tracker.php b/includes/class-psa-cost-tracker.php index 8853252..6eec5d9 100755 --- a/includes/class-psa-cost-tracker.php +++ b/includes/class-psa-cost-tracker.php @@ -1,13 +1,14 @@ prefix . 'psa_api_logs'; - $charset_collate = $wpdb->get_charset_collate(); + $table = $wpdb->prefix . 'psa_api_logs'; + $charset = $wpdb->get_charset_collate(); - $sql = "CREATE TABLE $table_name ( + $sql = "CREATE TABLE $table ( id BIGINT AUTO_INCREMENT PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, provider VARCHAR(50) NOT NULL DEFAULT 'openrouter', @@ -42,13 +40,12 @@ class PSA_Cost_Tracker { token_source VARCHAR(20) NOT NULL DEFAULT 'api', INDEX idx_created_at (created_at), INDEX idx_request_type (request_type) - ) $charset_collate;"; + ) $charset;"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); - // Check for errors during table creation. if ( ! empty( $wpdb->last_error ) ) { error_log( 'PSA: Table creation error: ' . $wpdb->last_error ); } @@ -58,63 +55,44 @@ class PSA_Cost_Tracker { * Log an API call with token counts and cost. * * @param array $data { - * @type string $provider API provider (default: 'openrouter') - * @type string $model Model identifier - * @type int $prompt_tokens Tokens in prompt - * @type int $completion_tokens Tokens in completion - * @type int $total_tokens Total tokens (or 0 to auto-sum) - * @type string $request_type 'validation' or 'generation' - * @type string $peptide_name Name of peptide being processed - * @type bool $success Whether API call succeeded - * @type float $estimated_cost_usd Cost (or 0 to auto-calculate) - * @type string $token_source 'api' (from usage response), 'estimated' (character-based), or 'none' + * @type string $provider, @type string $model, @type int $prompt_tokens, + * @type int $completion_tokens, @type int $total_tokens, @type string $request_type, + * @type string $peptide_name, @type bool $success, @type float $estimated_cost_usd, + * @type string $token_source 'api'|'estimated'|'none' * } * @return int|false Insert ID or false on error. */ public static function log_api_call( array $data ) { global $wpdb; - $table_name = $wpdb->prefix . 'psa_api_logs'; - - // Sanitize and prepare data. - $provider = $data['provider'] ?? 'openrouter'; - $model = $data['model'] ?? ''; - $prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 ); + $table = $wpdb->prefix . 'psa_api_logs'; + $prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 ); $completion_tokens = (int) ( $data['completion_tokens'] ?? 0 ); - $total_tokens = (int) ( $data['total_tokens'] ?? 0 ); - $request_type = $data['request_type'] ?? 'generation'; - $peptide_name = $data['peptide_name'] ?? ''; - $success = ! empty( $data['success'] ) ? 1 : 0; - $estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 ); - $token_source = $data['token_source'] ?? 'api'; + $total_tokens = (int) ( $data['total_tokens'] ?? 0 ); + $estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 ); - // Auto-sum tokens if not provided. if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) { $total_tokens = $prompt_tokens + $completion_tokens; } - - // Auto-calculate cost if not provided and we have token data. if ( 0 === $estimated_cost && $prompt_tokens > 0 ) { - $estimated_cost = self::estimate_cost( $model, $prompt_tokens, $completion_tokens ); + $estimated_cost = self::estimate_cost( $data['model'] ?? '', $prompt_tokens, $completion_tokens ); } $insert_data = array( - 'provider' => sanitize_text_field( $provider ), - 'model' => sanitize_text_field( $model ), + 'provider' => sanitize_text_field( $data['provider'] ?? 'openrouter' ), + 'model' => sanitize_text_field( $data['model'] ?? '' ), 'prompt_tokens' => $prompt_tokens, 'completion_tokens' => $completion_tokens, 'total_tokens' => $total_tokens, 'estimated_cost_usd' => $estimated_cost, - 'request_type' => sanitize_text_field( $request_type ), - 'peptide_name' => sanitize_text_field( $peptide_name ), - 'success' => $success, - 'token_source' => sanitize_text_field( $token_source ), + 'request_type' => sanitize_text_field( $data['request_type'] ?? 'generation' ), + 'peptide_name' => sanitize_text_field( $data['peptide_name'] ?? '' ), + 'success' => ! empty( $data['success'] ) ? 1 : 0, + 'token_source' => sanitize_text_field( $data['token_source'] ?? 'api' ), ); - $insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $result = $wpdb->insert( $table_name, $insert_data, $insert_format ); + $result = $wpdb->insert( $table, $insert_data, array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' ) ); if ( false === $result ) { error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error ); @@ -125,167 +103,23 @@ class PSA_Cost_Tracker { } /** - * Get total spend for a given month. + * Check if current month's budget has been exceeded. Budget of 0 = unlimited. * - * @param int|null $year Year (defaults to current year). - * @param int|null $month Month (defaults to current month). - * @return float Total USD spent in the month. - */ - public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float { - global $wpdb; - - if ( null === $year ) { - $year = (int) gmdate( 'Y' ); - } - if ( null === $month ) { - $month = (int) gmdate( 'm' ); - } - - $table_name = $wpdb->prefix . 'psa_api_logs'; - - // Ensure valid month. - $month = max( 1, min( 12, (int) $month ) ); - - // Build date range for the month. - $start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) ); - $end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ); - $end_date = $end_date . ' 23:59:59'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $result = $wpdb->get_var( - $wpdb->prepare( - 'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix. - $start_date, - $end_date - ) - ); - - return floatval( $result ); - } - - /** - * Get total token usage for a given month. - * - * @param int|null $year Year (defaults to current year). - * @param int|null $month Month (defaults to current month). - * @return int Total tokens used in the month. - */ - public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { - global $wpdb; - - if ( null === $year ) { - $year = (int) gmdate( 'Y' ); - } - if ( null === $month ) { - $month = (int) gmdate( 'm' ); - } - - $table_name = $wpdb->prefix . 'psa_api_logs'; - - // Ensure valid month. - $month = max( 1, min( 12, (int) $month ) ); - - // Build date range for the month. - $start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) ); - $end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ); - $end_date = $end_date . ' 23:59:59'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $result = $wpdb->get_var( - $wpdb->prepare( - 'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix. - $start_date, - $end_date - ) - ); - - return (int) $result; - } - - /** - * Count how many API calls this month used character-based token estimates. - * - * Why: Lets the admin UI flag when reported costs include approximations - * so operators can distinguish measured spend from estimated spend. - * - * @param int|null $year Year (defaults to current year). - * @param int|null $month Month (defaults to current month). - * @return int Number of estimated rows this month. - */ - public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { - global $wpdb; - - if ( null === $year ) { - $year = (int) gmdate( 'Y' ); - } - if ( null === $month ) { - $month = (int) gmdate( 'm' ); - } - - $table_name = $wpdb->prefix . 'psa_api_logs'; - $month = max( 1, min( 12, (int) $month ) ); - - $start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) ); - $end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $result = $wpdb->get_var( - $wpdb->prepare( - 'SELECT COUNT(*) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix. - $start_date, - $end_date, - 'estimated' - ) - ); - - return (int) $result; - } - - /** - * Check if current month's budget has been exceeded. - * Budget of 0 means unlimited. - * - * @return bool True if budget exceeded, false otherwise. + * @return bool True if budget exceeded. */ public static function is_budget_exceeded(): bool { $settings = get_option( 'psa_settings', array() ); $budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ); - // Budget of 0 = unlimited. Use loose comparison because floatval() returns float. if ( 0.0 === $budget ) { return false; } - $current_spend = self::get_monthly_spend(); - return $current_spend >= $budget; - } - - /** - * Get the most recent API call logs. - * - * @param int $limit Maximum number of logs to return. - * @return array Array of log objects. - */ - public static function get_recent_logs( int $limit = 20 ): array { - global $wpdb; - - $table_name = $wpdb->prefix . 'psa_api_logs'; - $limit = max( 1, (int) $limit ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $results = $wpdb->get_results( - $wpdb->prepare( - 'SELECT * FROM ' . $table_name . ' ORDER BY created_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix. - $limit - ) - ); - - return is_array( $results ) ? $results : array(); + return PSA_Cost_Reporter::get_monthly_spend() >= $budget; } /** * Estimate cost for an API call based on model and token counts. - * Uses known pricing, defaults to conservative estimate for unknown models. * * @param string $model Model identifier. * @param int $prompt_tokens Tokens in prompt. @@ -293,45 +127,45 @@ class PSA_Cost_Tracker { * @return float Estimated cost in USD. */ public static function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { - // Known model pricing per 1 million tokens (input, output). - // Why: OpenRouter pricing varies by model; we maintain known rates here and - // fall back to a conservative default for unlisted models so cost tracking - // always produces a usable upper-bound estimate. + // Per 1M tokens (input, output). Conservative default for unknown models. $pricing = array( - 'google/gemini-2.5-flash' => array( 0.15, 0.60 ), - 'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ), - 'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ), - 'deepseek/deepseek-chat' => array( 0.27, 1.10 ), - 'deepseek/deepseek-r1' => array( 0.55, 2.19 ), - 'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ), + 'google/gemini-2.5-flash' => array( 0.15, 0.60 ), + 'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ), + 'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ), + 'deepseek/deepseek-chat' => array( 0.27, 1.10 ), + 'deepseek/deepseek-r1' => array( 0.55, 2.19 ), + 'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ), ); $model = sanitize_text_field( $model ); - if ( isset( $pricing[ $model ] ) ) { - list( $input_price, $output_price ) = $pricing[ $model ]; + list( $in, $out ) = $pricing[ $model ]; } else { - // Conservative default for unknown models. - $input_price = 1.0; - $output_price = 2.0; + $in = 1.0; + $out = 2.0; } - // Convert per 1M to per token. - $input_cost = ( $input_price / 1000000 ) * $prompt_tokens; - $output_cost = ( $output_price / 1000000 ) * $completion_tokens; - - return (float) ( $input_cost + $output_cost ); + return (float) ( ( $in / 1000000 ) * $prompt_tokens + ( $out / 1000000 ) * $completion_tokens ); } - /** - * Drop the API logs table during uninstall. - */ + /** Drop the API logs table during uninstall. */ public static function drop_table(): void { global $wpdb; - - $table_name = $wpdb->prefix . 'psa_api_logs'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix. - $wpdb->query( 'DROP TABLE IF EXISTS ' . $table_name ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'psa_api_logs' ); } + + // ── Backward-compatible proxies (reporting moved to PSA_Cost_Reporter) ── + + /** @see PSA_Cost_Reporter::get_monthly_spend() */ + public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float { return PSA_Cost_Reporter::get_monthly_spend( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_monthly_tokens() */ + public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_tokens( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_monthly_estimated_count() */ + public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_estimated_count( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_recent_logs() */ + public static function get_recent_logs( int $limit = 20 ): array { return PSA_Cost_Reporter::get_recent_logs( $limit ); } } diff --git a/peptide-search-ai.php b/peptide-search-ai.php index caee81a..5142a8d 100755 --- a/peptide-search-ai.php +++ b/peptide-search-ai.php @@ -26,6 +26,7 @@ require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type-meta.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-search-handler.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php'; +require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-reporter.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-openrouter.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-kb-builder.php';