0 && $usd < 0.01 ) ? 4 : 2 ); $code = (string) Settings::get( 'currency2' ); $rate = (float) Settings::get( 'currency2_rate' ); if ( '' !== $code && $rate > 0 ) { $text .= ' (≈ ' . number_format_i18n( $usd * $rate, 2 ) . ' ' . $code . ')'; } return $text; } /** * Price per 1M tokens as a plain decimal for inputs. * * @param int $micros Micros. * @return string */ private static function decimal( $micros ) { return rtrim( rtrim( number_format( $micros / 1000000, 6, '.', '' ), '0' ), '.' ); } /** * Render all sections (outside the settings form). * * @return void */ public static function render() { self::render_usage(); self::render_prices(); self::render_calculator(); self::render_tips(); } /** * Models worth showing in the price table. * * @return array model id => provider. */ private static function relevant_models() { $models = array(); foreach ( array( 'economy', 'balanced', 'best' ) as $tier ) { $prediction = ModelResolver::predict( $tier ); foreach ( $prediction['plausible'] as $pm ) { $models[ $pm[1] ] = $pm[0]; } } $last = get_option( 'ailsug_last_model', array() ); if ( is_array( $last ) && ! empty( $last['model'] ) ) { $models[ (string) $last['model'] ] = (string) $last['provider']; } $block = (string) get_option( Gateway::PRICE_BLOCK, '' ); if ( '' !== $block ) { $models[ $block ] = $models[ $block ] ?? ''; } foreach ( Pricing::overrides() as $id => $row ) { $models[ $id ] = $row['provider']; } $only = Availability::only_model(); if ( $only ) { $models[ $only[1] ] = $only[0]; } return $models; } /** * Price table with editable overrides. * * @return void */ private static function render_prices() { $table = Pricing::table(); $overrides = Pricing::overrides(); $models = self::relevant_models(); $connected = Availability::text_models(); echo '

' . esc_html__( 'Prices', 'linkcortex' ) . '

'; echo '

' . esc_html( sprintf( /* translators: %s: date. */ __( 'Prices are in US dollars per 1 million tokens (a token is roughly ¾ of a word). Built-in prices were checked on the providers\' official pages on %s. Prices change: please verify them and correct any that are out of date.', 'linkcortex' ), Pricing::as_of() ) ) . '

'; echo '

'; $names = array( 'anthropic' => 'Anthropic', 'google' => 'Google', 'openai' => 'OpenAI', ); foreach ( Pricing::sources() as $provider => $url ) { /* translators: %s: provider name. */ echo '' . esc_html( sprintf( __( 'Verify %s prices', 'linkcortex' ), $names[ $provider ] ?? ucfirst( $provider ) ) ) . ' ' . esc_html__( '(opens in a new tab)', 'linkcortex' ) . '   '; } echo '

'; echo '
'; wp_nonce_field( 'ailsug_save_prices' ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; if ( ! $models ) { echo ''; } $i = 0; foreach ( $models as $model => $provider ) { $price = Pricing::price_for( $provider, $model ); $key = 'ailsug-price-' . $i; echo ''; printf( '', esc_attr( $key ), /* translators: %s: model id. */ esc_html( sprintf( __( 'Input price for %s', 'linkcortex' ), $model ) ), (int) $i, esc_attr( $price ? self::decimal( $price['input'] ) : '' ) ); printf( '', esc_attr( $key ), /* translators: %s: model id. */ esc_html( sprintf( __( 'Output price for %s', 'linkcortex' ), $model ) ), (int) $i, esc_attr( $price ? self::decimal( $price['output'] ) : '' ) ); if ( ! $price ) { $source = '' . esc_html__( 'Unknown: cost shown as unknown, estimate unavailable', 'linkcortex' ) . ''; } elseif ( 'user' === $price['origin'] ) { $source = '' . esc_html__( 'Your price', 'linkcortex' ) . ''; } else { /* translators: %s: date. */ $source = esc_html( sprintf( __( 'Built-in, as of %s', 'linkcortex' ), Pricing::as_of() ) ); } echo ''; ++$i; }//end foreach // Empty row to add a model. printf( '', esc_html__( 'Model ID', 'linkcortex' ), (int) $i, esc_attr__( 'Add a model ID', 'linkcortex' ), esc_html__( 'Provider', 'linkcortex' ), esc_attr__( 'provider', 'linkcortex' ), esc_html__( 'Input price', 'linkcortex' ), esc_html__( 'Output price', 'linkcortex' ), esc_html__( 'Optional: add a price for another model.', 'linkcortex' ) ); echo '
' . esc_html__( 'Model', 'linkcortex' ) . '' . esc_html__( 'Input price ($ / 1M)', 'linkcortex' ) . '' . esc_html__( 'Output price ($ / 1M)', 'linkcortex' ) . '' . esc_html__( 'Source', 'linkcortex' ) . '' . esc_html__( 'Reset', 'linkcortex' ) . '
' . esc_html__( 'Connect an AI provider to see the models you can use. You can still add a price below.', 'linkcortex' ) . '
' . esc_html( $model ) . '
' . esc_html( $provider ) . ''; printf( '', (int) $i, esc_attr( $model ), esc_attr( $provider ) ); echo '
' . wp_kses_post( $source ) . ''; if ( isset( $overrides[ $model ] ) ) { printf( '', (int) $i, esc_html__( 'Use built-in', 'linkcortex' ) ); } echo '

%8$s
'; submit_button( __( 'Save prices', 'linkcortex' ), 'secondary' ); echo '
'; $all = 0; foreach ( $connected as $list ) { $all += count( $list ); } if ( $all > count( $models ) ) { echo '
' . esc_html__( 'All text models your connected providers offer', 'linkcortex' ) . '
'; } unset( $table ); } /** * Site numbers to prefill the calculator. * * @return array */ public static function site_stats() { global $wpdb; $t = Activator::tables(); $types = array(); $total = 0; foreach ( (array) Settings::get( 'post_types' ) as $type ) { $count = wp_count_posts( $type ); $n = isset( $count->publish ) ? (int) $count->publish : 0; $obj = get_post_type_object( $type ); $types[] = array( 'type' => $type, 'label' => $obj ? $obj->labels->name : $type, 'count' => $n, ); $total += $n; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $stats = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS n, AVG(word_count) AS words, SUM(keyword_source IN (\'manual\',\'seo_plugin\')) AS known FROM %i', $t['index'] ), ARRAY_A ); $words = (int) round( (float) ( $stats['words'] ?? 0 ) ); if ( $words <= 0 ) { $words = 800; } $share = (int) ( $stats['n'] ?? 0 ) > 0 ? 1 - ( (int) $stats['known'] / (int) $stats['n'] ) : 1.0; $recent = 0; $types_list = (array) Settings::get( 'post_types' ); if ( $types_list ) { $q = new \WP_Query( array( 'post_type' => $types_list, 'post_status' => 'publish', 'date_query' => array( array( 'after' => '90 days ago' ) ), 'fields' => 'ids', 'posts_per_page' => 1, ) ); $recent = (int) $q->found_posts; } return array( 'types' => $types, 'posts' => $total, 'words' => $words, 'new_per_month' => (int) ceil( $recent / 3 ), 'keyword_share' => round( $share, 2 ), 'candidates' => (int) Settings::get( 'shortlist_size', 15 ), 'suggestions' => (int) Settings::get( 'max_suggestions', 5 ), 'tier' => (string) Settings::get( 'quality', 'economy' ), ); } /** * Interactive calculator (cost.js fills the results). * * @return void */ private static function render_calculator() { $s = self::site_stats(); echo '

' . esc_html__( 'Cost calculator', 'linkcortex' ) . '

'; echo '

' . esc_html__( 'Try different numbers to see what scanning your site would cost. Nothing is sent to AI here.', 'linkcortex' ) . '

'; echo '
'; $per_type = array(); foreach ( $s['types'] as $type ) { $per_type[] = $type['label'] . ': ' . number_format_i18n( $type['count'] ); } $fields = array( 'posts' => array( __( 'Pages to scan', 'linkcortex' ), $s['posts'], implode( ' · ', $per_type ) ), 'words' => array( __( 'Average words per page', 'linkcortex' ), $s['words'], __( 'Measured from your indexed pages.', 'linkcortex' ) ), 'new_per_month' => array( __( 'New pages per month', 'linkcortex' ), $s['new_per_month'], __( 'Based on the last 90 days.', 'linkcortex' ) ), 'candidates' => array( __( 'Candidate pages per page', 'linkcortex' ), $s['candidates'], '' ), 'suggestions' => array( __( 'Suggestions per page', 'linkcortex' ), $s['suggestions'], '' ), ); foreach ( $fields as $key => $f ) { printf( '


%4$s

', esc_attr( $key ), esc_html( $f[0] ), (int) $f[1], '' !== $f[2] ? ' ' . esc_html( $f[2] ) . '' : '' ); } echo '


'; echo '

'; $outputs = array( 'full_scan' => __( 'First full scan', 'linkcortex' ), 'per_post' => __( 'Each newly published page', 'linkcortex' ), 'monthly' => __( 'Estimated monthly upkeep', 'linkcortex' ), 'per_sugg' => __( 'Cost per suggestion', 'linkcortex' ), ); foreach ( $outputs as $key => $label ) { echo '
' . esc_html( $label ) . '
—
'; } echo '

'; echo '
' . esc_html__( 'How is this calculated?', 'linkcortex' ) . '
'; echo '
'; } /** * Usage dashboard. * * @return void */ private static function render_usage() { $b = Budget::status(); echo '

' . esc_html__( 'AI spend this month', 'linkcortex' ) . '

'; echo '

' . esc_html__( 'Figures are estimates based on how much text (tokens: about ¾ of a word each) the provider reports reading and writing. Your provider\'s invoice is the source of truth.', 'linkcortex' ) . '

'; if ( $b['cap'] > 0 ) { $pct = (int) round( $b['percent'] ); printf( '
%1$s

%4$s

', esc_html( sprintf( /* translators: 1: spent, 2: cap, 3: percent. */ __( '%1$s of %2$s estimated budget used (%3$s%%)', 'linkcortex' ), self::money( $b['spent'] ), self::money( $b['cap'] ), number_format_i18n( $pct ) ) ), (int) $pct, esc_attr__( 'Share of the monthly budget used', 'linkcortex' ), $b['reserved'] > 0 ? esc_html( sprintf( /* translators: %s: amount. */ __( 'Plus %s set aside for AI requests in progress.', 'linkcortex' ), self::money( $b['reserved'] ) ) ) : '', $b['reached'] ? ' is-reached' : ( $b['alert'] ? ' is-alert' : '' ) ); } else { /* translators: %s: amount. */ echo '

' . esc_html( sprintf( __( 'Estimated spend: %s. No monthly cap is set.', 'linkcortex' ), self::money( $b['spent'] ) ) ) . '

'; }//end if $tokens = UsageLog::month_tokens(); echo ''; $labels = array( 'keyword' => __( 'Finding keywords', 'linkcortex' ), 'rank' => __( 'Choosing links', 'linkcortex' ), ); echo '
'; foreach ( array( 'task' => __( 'By task', 'linkcortex' ), 'model' => __( 'By model', 'linkcortex' ), 'day' => __( 'By day', 'linkcortex' ), ) as $by => $title ) { $rows = UsageLog::summary( $by ); echo '

' . esc_html( $title ) . '

'; if ( ! $rows ) { echo '

' . esc_html__( 'No AI use this month yet.', 'linkcortex' ) . '

'; continue; } $max = max( 1, max( array_map( 'intval', wp_list_pluck( $rows, 'cost' ) ) ) ); echo ''; foreach ( $rows as $row ) { $label = 'task' === $by ? ( $labels[ $row['label'] ] ?? $row['label'] ) : $row['label']; $width = (int) round( (int) $row['cost'] * 100 / $max ); printf( '', esc_html( $label ), esc_html( number_format_i18n( (int) $row['calls'] ) ), esc_html( number_format_i18n( (int) ( $row['input_tokens'] ?? 0 ) ) ), esc_html( number_format_i18n( (int) ( $row['output_tokens'] ?? 0 ) ) ), (int) $width, esc_html( self::money( (int) $row['cost'] ) ) ); } echo '
' . esc_html( $title ) . '' . esc_html__( 'Requests', 'linkcortex' ) . '' . esc_html__( 'Tokens (in / out)', 'linkcortex' ) . '' . esc_html__( 'Estimated cost', 'linkcortex' ) . '
%1$s%2$s%3$s / %4$s %6$s
'; }//end foreach echo ''; $runs = UsageLog::recent_runs( 10 ); echo '

' . esc_html__( 'Recent AI runs: estimate vs actual', 'linkcortex' ) . '

'; if ( ! $runs ) { echo '

' . esc_html__( 'No AI runs yet.', 'linkcortex' ) . '

'; } else { echo ''; foreach ( array( __( 'Started', 'linkcortex' ), __( 'Type', 'linkcortex' ), __( 'Pages', 'linkcortex' ), __( 'Estimated before', 'linkcortex' ), __( 'Actual (estimated from usage)', 'linkcortex' ), __( 'Model that answered', 'linkcortex' ), __( 'Status', 'linkcortex' ) ) as $h ) { echo ''; } echo ''; foreach ( $runs as $run ) { $est = $run['est_high_micros'] > 0 ? self::money( (int) $run['est_low_micros'] ) . ' – ' . self::money( (int) $run['est_high_micros'] ) : __( 'unavailable', 'linkcortex' ); $act = self::money( (int) $run['actual'] ); if ( (int) $run['calls'] > 0 && ! (int) $run['price_known'] ) { $act .= ' ' . __( '(some prices unknown)', 'linkcortex' ); } printf( '', esc_html( get_date_from_gmt( $run['created_at'], get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ), esc_html( $run['type'] ), esc_html( number_format_i18n( (int) $run['total'] ) ), esc_html( $est ), esc_html( $act ), esc_html( (string) $run['models'] ), esc_html( $run['status'] ) ); } echo '
' . esc_html( $h ) . '
%1$s%2$s%3$s%4$s%5$s%6$s%7$s
'; }//end if $cal = array( Calibrator::factors( 'keyword' ), Calibrator::factors( 'rank' ) ); echo '

' . esc_html( sprintf( /* translators: 1: samples for keywords, 2: samples for links. */ __( 'Estimates improve automatically after 20 real requests per task (so far: %1$d for keywords, %2$d for links).', 'linkcortex' ), $cal[0]['samples'], $cal[1]['samples'] ) ) . '

'; $export = wp_nonce_url( admin_url( 'admin-post.php?action=ailsug_export_usage' ), 'ailsug_export_usage' ); echo '

' . esc_html__( 'Export usage CSV', 'linkcortex' ) . '

'; echo '
'; wp_nonce_field( 'ailsug_reset_usage' ); echo ''; echo ' '; submit_button( __( 'Reset counters', 'linkcortex' ), 'delete small', 'submit', false ); echo '
'; } /** * Cost-saving tips. * * @return void */ private static function render_tips() { echo '

' . esc_html__( 'Ways to spend less', 'linkcortex' ) . '

'; } /** * Save price overrides. * * @return void */ public static function save_prices() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_save_prices' ); $overrides = Pricing::overrides(); $bundled = Pricing::bundled()['models'] ?? array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each field is sanitized below. $rows = isset( $_POST['prices'] ) && is_array( $_POST['prices'] ) ? wp_unslash( $_POST['prices'] ) : array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $model = sanitize_text_field( (string) ( $row['model'] ?? '' ) ); $provider = sanitize_key( (string) ( $row['provider'] ?? '' ) ); if ( '' === $model || ! preg_match( '/^[A-Za-z0-9._\/:-]{1,128}$/', $model ) ) { continue; } if ( ! empty( $row['reset'] ) ) { unset( $overrides[ $model ] ); continue; } $in = trim( (string) ( $row['input'] ?? '' ) ); $out = trim( (string) ( $row['output'] ?? '' ) ); if ( '' === $in || '' === $out ) { continue; } $in_m = Settings::to_micros( $in ); $out_m = Settings::to_micros( $out ); // Only store as an override when it differs from the built-in price. $builtin = isset( $bundled[ $model ] ) ? array( Pricing::decimal_to_micros( $bundled[ $model ]['input_per_million'] ), Pricing::decimal_to_micros( $bundled[ $model ]['output_per_million'] ) ) : null; if ( $builtin && ! isset( $overrides[ $model ] ) && $builtin[0] === $in_m && $builtin[1] === $out_m ) { continue; } $overrides[ $model ] = array( 'provider' => $provider, 'input' => $in_m, 'output' => $out_m, ); }//end foreach Pricing::save_overrides( $overrides ); Budget::on_change(); add_settings_error( 'ailsug', 'prices', __( 'Prices saved.', 'linkcortex' ), 'success' ); set_transient( 'settings_errors', get_settings_errors(), 30 ); wp_safe_redirect( add_query_arg( 'settings-updated', 'true', Menu::url( 'settings', array( 'tab' => 'ai' ) ) ) . '#ailsug-prices' ); exit; } /** * Usage CSV. * * @return void */ public static function export_usage() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_export_usage' ); $out = array(); foreach ( UsageLog::month_rows() as $r ) { $out[] = array( $r['created_at'], $r['run_id'], $r['task'], $r['provider'], $r['model'], $r['requested_model'], $r['est_input_tokens'], $r['input_tokens'], $r['est_output_tokens'], $r['output_tokens'], number_format( $r['pre_cost_micros'] / 1000000, 6, '.', '' ), number_format( $r['est_cost_micros'] / 1000000, 6, '.', '' ), $r['price_known'] ? 'yes' : 'no', $r['is_estimate'] ? 'yes' : 'no', $r['post_id'] ); } Csv::download( 'internal-links-ai-usage-' . gmdate( 'Y-m' ) . '.csv', array( 'Time (UTC)', 'Run', 'Task', 'Provider', 'Model that answered', 'Preferred model', 'Estimated input tokens', 'Input tokens', 'Estimated output tokens', 'Output tokens', 'Estimated cost before (USD)', 'Cost from usage (USD)', 'Price known', 'Tokens estimated', 'Post ID' ), $out ); } /** * Reset this month's counters (confirmed). * * @return void */ public static function reset_usage() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_reset_usage' ); if ( ! empty( $_POST['confirm'] ) ) { UsageLog::reset_month(); Budget::reset_period(); add_settings_error( 'ailsug', 'reset', __( 'This month\'s usage counters were reset.', 'linkcortex' ), 'success' ); set_transient( 'settings_errors', get_settings_errors(), 30 ); } wp_safe_redirect( add_query_arg( 'settings-updated', 'true', Menu::url( 'settings', array( 'tab' => 'ai' ) ) ) . '#ailsug-usage' ); exit; } /** * Budget and price notices for administrators (plugin screens and the WP dashboard only). * * @return void */ public static function notices() { if ( ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || false === strpos( (string) $screen->id, Menu::SLUG ) ) { return; } $b = Budget::status(); $link = '' . esc_html__( 'Settings → AI & Cost', 'linkcortex' ) . ''; if ( $b['reached'] ) { /* translators: %s: link to settings. */ echo '

' . wp_kses_post( sprintf( __( 'LinkCortex: the estimated monthly AI budget is used up, so no new AI work will start. Your page index and link map keep working. Raise the cap in %s or wait until next month.', 'linkcortex' ), $link ) ) . '

'; } elseif ( $b['alert'] ) { /* translators: 1: percent, 2: link to settings. */ echo '

' . wp_kses_post( sprintf( __( 'LinkCortex: %1$s%% of the estimated monthly AI budget is used. See %2$s.', 'linkcortex' ), number_format_i18n( (int) round( $b['percent'] ) ), $link ) ) . '

'; } $block = (string) get_option( Gateway::PRICE_BLOCK, '' ); if ( '' !== $block && ! Pricing::price_for( '', $block ) ) { /* translators: 1: model id, 2: link to settings. */ echo '

' . wp_kses_post( sprintf( __( 'LinkCortex: the AI model "%1$s" answered, but its price is unknown. Paid AI runs are paused until you enter its price in %2$s.', 'linkcortex' ), esc_html( $block ), $link ) ) . '

'; } } }