byok > trial > none *───────────────────────────────────────────────────────────────*/ function aatb_active_mode(): string { $opt = get_option( AATB_SETTINGS_SLUG, [] ); if ( ! empty( $opt['site_token'] ) ) return 'pro'; if ( ! empty( $opt['openai_key'] ) ) return 'byok'; if ( ! empty( $opt['trial_token'] ) ) return 'trial'; return 'none'; } /*─────────────────────────────────────────────────────────────── * Turn an API error value into a readable message. * APIs may return a plain string or a structured object such as * {"error":{"message":"..."}}; anything else falls back. *───────────────────────────────────────────────────────────────*/ function aatb_error_text( $value, string $fallback = '' ): string { if ( is_scalar( $value ) && '' !== (string) $value ) return (string) $value; if ( is_array( $value ) ) { foreach ( [ 'message', 'detail', 'error', 'msg' ] as $k ) { if ( isset( $value[ $k ] ) && is_scalar( $value[ $k ] ) && '' !== (string) $value[ $k ] ) { return (string) $value[ $k ]; } } } return $fallback; } /*─────────────────────────────────────────────────────────────── * BYOK: Direct OpenAI Vision API call *───────────────────────────────────────────────────────────────*/ function aatb_call_openai_direct( string $api_key, string $image_url, string $prompt ): array { $body = [ 'model' => 'gpt-4.1-mini', 'messages' => [ [ 'role' => 'user', 'content' => [ [ 'type' => 'text', 'text' => $prompt ], [ 'type' => 'image_url', 'image_url' => [ 'url' => $image_url, 'detail' => 'low' ] ], ], ], ], 'max_tokens' => 300, 'temperature' => 0.2, ]; $res = wp_remote_post( 'https://api.openai.com/v1/chat/completions', [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $body ), 'timeout' => 30, ] ); if ( is_wp_error( $res ) ) { return [ 'error' => $res->get_error_message() ]; } $code = (int) wp_remote_retrieve_response_code( $res ); $raw = wp_remote_retrieve_body( $res ); $json = json_decode( $raw, true ); if ( $code === 401 ) { return [ 'error' => 'Invalid OpenAI API key. Please check your key in Settings.' ]; } if ( $code === 429 ) { return [ 'error' => 'OpenAI rate limit reached. Please try again in a moment.' ]; } if ( $code !== 200 ) { $msg = $json['error']['message'] ?? "OpenAI HTTP {$code}"; return [ 'error' => $msg ]; } $content = $json['choices'][0]['message']['content'] ?? ''; $parsed = json_decode( $content, true ); if ( ! $parsed ) { if ( preg_match( '/\{[^}]*"alt_text"\s*:\s*"([^"]+)"/s', $content, $m ) ) { return [ 'alt_text' => $m[1] ]; } return [ 'alt_text' => trim( str_replace( [ '{', '}', '"' ], '', $content ) ) ]; } return [ 'alt_text' => $parsed['alt_text'] ?? '' ]; } /*─────────────────────────────────────────────────────────────── * Trial: Call backend /trial-alt endpoint *───────────────────────────────────────────────────────────────*/ function aatb_call_trial_api( string $trial_token, string $image_url, string $prompt ): array { $data = [ 'trial_token' => $trial_token, 'image_url' => $image_url, 'prompt' => $prompt, 'domain' => home_url(), ]; $res = wp_remote_post( 'https://rankpilotai.com/wp-json/rankpilotai/v1/trial-alt', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $data ), 'timeout' => 30, ] ); if ( is_wp_error( $res ) ) { return [ 'error' => $res->get_error_message() ]; } $code = (int) wp_remote_retrieve_response_code( $res ); $json = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code !== 200 ) { $msg = isset( $json['error'] ) && is_scalar( $json['error'] ) ? (string) $json['error'] : "Trial API HTTP {$code}"; return [ 'error' => $msg, 'exhausted' => ! empty( $json['exhausted'] ) ]; } return $json; } /*─────────────────────────────────────────────────────────────── * Safe fallback: model alias pack (always returns an array) *───────────────────────────────────────────────────────────────*/ if ( ! function_exists('aatb_model_alias_pack') ) { function aatb_model_alias_pack( string $model ): array { $m = strtolower( trim( (string) $model ) ); switch ( $m ) { // BALANCED case 'gpt-4.1': case 'gpt-4-1': case 'gpt_4_1': case 'gpt41': case 'gpt4.1': return [ 'canonical' => 'gpt-4.1', 'engine' => 'gpt-4.1', 'family' => 'gpt41', 'aliases' => ['gpt-4.1','gpt-4-1','gpt_4_1','gpt41','gpt4.1'], ]; // PREMIUM (4o and 4-turbo map to the same family) case 'gpt-4o': case 'gpt4o': case 'o4': case '4o': case 'gpt-4-turbo': case 'gpt4-turbo': return [ 'canonical' => 'gpt-4o', 'engine' => 'gpt-4o', 'family' => 'gpt4o', 'aliases' => ['gpt-4o','gpt4o','o4','4o','gpt-4-turbo','gpt4-turbo'], ]; // ECONOMY (default) default: return [ 'canonical' => 'gpt-4.1-mini', 'engine' => 'gpt-4.1-mini', 'family' => 'gpt4omini', 'aliases' => ['gpt-4.1-mini','gpt-4o-mini','gpt4o-mini','4o-mini','mini'], ]; } } } /* Safe fallbacks (skipped if defined in the main file) */ if ( ! function_exists('aatb_model_cost') ) { function aatb_model_cost( string $model ): int { return match ( strtolower(trim($model)) ) { 'gpt-4o' => 5, 'gpt-4.1' => 3, 'gpt-4.1-mini' => 1, default => 1, }; } } if ( ! function_exists('aatb_model_tier') ) { function aatb_model_tier( string $model ): string { return match ( strtolower(trim($model)) ) { 'gpt-4o' => 'premium', 'gpt-4.1' => 'balanced', default => 'economical', }; } } /*─────────────────────────────────────────────────────────────── * Institutional site key: rpedu__. * Returns the institution slug (2-20 lowercase letters or digits) * for a well-formed key, '' for any other key. The D modifier * stops "$" from accepting a trailing newline. *───────────────────────────────────────────────────────────────*/ function aatb_institution_slug( string $token ): string { return preg_match( '/^rpedu_([a-z0-9]{2,20})_[A-Za-z0-9_-]{32,}$/D', trim( $token ), $m ) ? $m[1] : ''; } /*─────────────────────────────────────────────────────────────── * Pro API base URL for a site key. * An institutional key goes to its institution's own API at * edu-.rankpilotai.com, every other key to rankpilotai.com. * The slug holds only [a-z0-9], so no key can point requests at * a host outside rankpilotai.com. *───────────────────────────────────────────────────────────────*/ function aatb_api_base( string $token ): string { $slug = aatb_institution_slug( $token ); if ( '' !== $slug ) { return 'https://edu-' . $slug . '.rankpilotai.com/v1'; } // A malformed institutional key is not sent anywhere, so it never reaches rankpilotai.com. // The marker is looked for anywhere in the key, not only at the start, so a key pasted // after a label, a quote or an invisible character (no-break or zero-width space, byte // order mark) is refused too. if ( false !== stripos( $token, 'rpedu' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- delivered as JSON to admin.js and shown with alert(); never echoed as HTML. The one HTML sink (admin/settings-page.php) escapes it there. throw new Exception( __( 'This institutional site key is not valid. Please copy it again exactly as you received it.', 'ai-alt-text-builder' ) ); } return 'https://rankpilotai.com/wp-json/rankpilotai/v1'; } /*─────────────────────────────────────────────────────────────── * What one image needs from the balance before a request. * rankpilotai.com charges by tier (1, 3 or 5 tokens). An * institutional plan counts images, so every tier needs 1. *───────────────────────────────────────────────────────────────*/ function aatb_request_cost( string $model, string $token ): int { return '' !== aatb_institution_slug( $token ) ? 1 : aatb_model_cost( $model ); } /** Shown when an institutional plan has no images left: it has no tokens to buy or top up. */ function aatb_institution_empty_text(): string { return __( 'Your institution\'s plan has no images left for the current plan period.', 'ai-alt-text-builder' ); } /*─────────────────────────────────────────────────────────────── * RankPilotAI universal HTTP wrapper * (set token_cost_hint once) *───────────────────────────────────────────────────────────────*/ function aatb_call_rankpilot_api( string $token, array $payload, string $route, string $method = 'POST' ){ $opts = get_option( 'aatb_settings', [] ); // If model missing, read from settings (mini by default) if ( empty( $payload['model'] ) ) { $payload['model'] = strtolower( $opts['model_choice'] ?? 'gpt-4.1-mini' ); } // Normalize model name $pack = aatb_model_alias_pack( (string) $payload['model'] ); $canonical = $pack['canonical'] ?? 'gpt-4.1-mini'; $payload['model'] = $canonical; $payload['engine'] = $pack['engine'] ?? $canonical; $payload['family'] = $pack['family'] ?? ''; $payload['aliases'] = $pack['aliases'] ?? []; // Billing / token cost (ensure via backward-compatible fields) $tier = function_exists('aatb_model_tier') ? aatb_model_tier($canonical) : 'economy'; $cost = function_exists('aatb_model_cost') ? aatb_model_cost($canonical) : 1; $payload['tier'] = $tier; // Set all names so either old or new backends can read them $payload['token_cost'] = $cost; // legacy name $payload['token_cost_hint'] = $cost; // new name $payload['model_cost'] = $cost; // legacy $payload['price_hint'] = $cost; // some stacks use this $payload['force_cost'] = $cost; // force if supported // Definitive charge hint for the backend $payload['charge'] = [ 'per' => 'alt-text', 'mode' => 'model_tier', 'tier' => $tier, 'tokens' => $cost, // economy=1, balanced=3, premium=5 'confirm' => 1, ]; // Language mirror (legacy backend compatibility) if ( isset($payload['language']) && ! isset($payload['lang']) ) { $payload['lang'] = $payload['language']; } // If no custom prompt provided, add from settings if ( empty($payload['custom_prompt']) && ! empty($opts['custom_prompt']) ) { $payload['custom_prompt'] = $opts['custom_prompt']; } // Base context $base = [ 'plugin' => 'ai-alt-text-builder', 'site_token' => trim( (string) $token ), 'site_url' => home_url(), 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ), ]; // Institutional keys use their institution's API; all routes follow the base. $url = aatb_api_base( $base['site_token'] ) . $route; $data = array_merge( $base, $payload ); $extra = [ 'headers' => [ 'User-Agent' => 'AI Alt Text Builder/' . ( defined('AATB_VERSION') ? AATB_VERSION : '1.x' ), 'Cache-Control' => 'no-cache', ], 'timeout' => 30, ]; // An institutional API answers with JSON and never redirects. Not following a redirect // keeps the key and the image URLs from being sent again to the host a 3xx names. if ( '' !== aatb_institution_slug( $base['site_token'] ) ) { $extra['redirection'] = 0; } $res = aatb_http_json( $url, $data, $method, $extra ); // On WP_Error, log just a short error message if ( is_wp_error( $res ) ) { if ( defined('WP_DEBUG') && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug output, gated by WP_DEBUG. error_log( '[AATB ERROR] HTTP error on route ' . $route . ': ' . implode( '; ', $res->get_error_messages() ) ); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- delivered as JSON to admin.js and shown with alert(); never echoed as HTML. The one HTML sink (admin/settings-page.php) escapes it there. throw new Exception( $res->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $res ); $body = wp_remote_retrieve_body( $res ); $json = json_decode( $body, true ); if ( $code !== 200 ) { $msg = null; if ( is_array($json) ) { foreach ( ['error_detail','message','detail','error','msg','reason'] as $k ) { if ( empty($json[$k]) ) continue; $msg = aatb_error_text( $json[$k] ); if ( '' !== $msg ) break; $msg = null; } } if ( defined('WP_DEBUG') && WP_DEBUG ) { $dbg = $msg ?: 'HTTP ' . $code; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug output, gated by WP_DEBUG. error_log('[AATB ERROR] Non-200 response on route ' . $route . ': ' . $dbg); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- delivered as JSON to admin.js and shown with alert(); never echoed as HTML. The one HTML sink (admin/settings-page.php) escapes it there. throw new Exception( $msg ?: "HTTP {$code}" ); } // A 200 answer must be a JSON object. An HTML page or an empty body (for example a // proxy challenge) would otherwise reach callers that expect an array and stop the page. if ( ! is_array( $json ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- delivered as JSON to admin.js and shown with alert(); never echoed as HTML. The one HTML sink (admin/settings-page.php) escapes it there. throw new Exception( __( 'The RankPilotAI service sent an unreadable answer. Please try again.', 'ai-alt-text-builder' ) ); } return $json; } /** * Fetch token status from RankPilotAI */ function aatb_check_token_status( string $token ): array { try { return aatb_call_rankpilot_api( $token, [ 'cache_bust' => time(), 'include' => 'balances,usage,limits' ], '/token-info', 'POST' ); } catch ( Exception $e ) { return [ 'error' => $e->getMessage(), 'allowed_models' => [ 'gpt-4.1-mini','gpt-4o','gpt-4.1' ] ]; } } /*─────────────────────────────────────────────────────────────── * token-info → Normalize GLOBAL “remaining” tokens *───────────────────────────────────────────────────────────────*/ function aatb_status_remaining( array $st ): int { if ( isset($st['remaining_total']) ) return (int)$st['remaining_total']; if ( isset($st['remaining_all']) ) return (int)$st['remaining_all']; if ( isset($st['global_remaining']) ) return (int)$st['global_remaining']; $fb = (int)($st['free_balance'] ?? 0); $pr = (int)($st['plan_remaining'] ?? 0); $pb = (int)($st['pack_balance'] ?? 0); $sum_bal = $fb + $pr + $pb; if ( $sum_bal > 0 ) return $sum_bal; $free = (int)($st['free'] ?? 0); $monthly = (int)($st['monthly_remaining'] ?? 0); $pack = (int)($st['token_pack'] ?? 0); $sum_legacy = $free + $monthly + $pack; if ( $sum_legacy > 0 ) return $sum_legacy; if ( isset($st['remaining']) ) return max(0, (int)$st['remaining']); $limit = (int)($st['plan_limit_total'] ?? $st['plan_limit'] ?? $st['token_limit'] ?? 0); $usage = null; foreach ( ['usage_total','all_usage','total_usage','token_usage','usage'] as $k ) { if ( isset($st[$k]) ) { $usage = (int)$st[$k]; break; } } if ( $usage === null ) $usage = (int)($st['site_usage'] ?? 0); return max(0, $limit - (int)$usage); } /*─────────────────────────────────────────────────────────────── * Optional: pick a random OpenAI key from constant/option *───────────────────────────────────────────────────────────────*/ function aatb_get_openai_key(): string { if ( defined( 'RP_OPENAI_KEYS' ) && RP_OPENAI_KEYS ) { $keys = preg_split( '/[\s,|]+/', RP_OPENAI_KEYS ); $keys = array_filter( array_map( 'trim', $keys ) ); if ( $keys ) return $keys[ array_rand( $keys ) ]; } $opt = get_option( 'aatb_settings', [] ); return $opt['openai_key'] ?? ''; } /*─────────────────────────────────────────────────────────────── * Alt-text score (length heuristic) *───────────────────────────────────────────────────────────────*/ function aatb_calc_score( string $alt = '' ): int { $l = strlen( trim( $alt ) ); return match ( true ) { $l === 0 => 0, $l <= 125 => 100, $l <= 150 => 80, $l <= 175 => 60, $l <= 200 => 20, default => 0, }; } /*-------------------------------------------------------------- * Quick helper: detect placeholder-like alt text *------------------------------------------------------------*/ function aatb_is_placeholder_alt( string $alt ): bool { return (bool) preg_match( '/(placeholder|cannot\sbe\sdisplayed|no specific content)/i', $alt ); }