get_error_code() ) { return $result; } // Rate limited — retry with exponential backoff. if ( $attempt < $max_retries ) { $delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s // Avoid blocking PHP workers during WP-Cron execution. if ( defined( 'DOING_CRON' ) && DOING_CRON ) { error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' ); return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' ); } error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' ); sleep( $delay ); } } error_log( 'PSA: All ' . $max_retries . ' attempts failed due to rate limiting.' ); return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached after ' . $max_retries . ' retries. Consider adding credits at openrouter.ai or switching to a non-free model.' ); } /** * Single OpenRouter API call (no retry). * * @param string $api_key The OpenRouter API key. * @param string $model The model identifier. * @param string $prompt The prompt text. * @param int $max_tokens Maximum tokens to generate. * @param string $request_type 'validation' or 'generation' (default). * @param string $peptide_name Peptide name for logging. * @return string|WP_Error Response text or error. */ private static function send_request_once( string $api_key, string $model, string $prompt, int $max_tokens = 4096, string $request_type = 'generation', string $peptide_name = '' ) { $site_url = get_bloginfo( 'url' ); $site_name = get_bloginfo( 'name' ); $response = wp_remote_post( self::API_URL, array( 'timeout' => 120, 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $api_key, 'HTTP-Referer' => $site_url, 'X-Title' => $site_name, ), 'body' => wp_json_encode( array( 'model' => $model, 'messages' => array( array( 'role' => 'system', 'content' => 'You are a scientific database assistant. Return only valid JSON.', ), array( 'role' => 'user', 'content' => $prompt, ), ), 'max_tokens' => $max_tokens, 'temperature' => 0.3, ) ), ) ); if ( is_wp_error( $response ) ) { error_log( 'PSA: OpenRouter connection error: ' . $response->get_error_message() ); return new WP_Error( 'api_error', 'Failed to connect to OpenRouter API: ' . $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); $raw_body = wp_remote_retrieve_body( $response ); $body = json_decode( $raw_body, true ); error_log( 'PSA: OpenRouter response code: ' . $code . ' for model: ' . $model ); if ( 200 !== $code ) { if ( 429 === $code ) { error_log( 'PSA: OpenRouter rate limited (429)' ); return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached. Please try again in a few moments.' ); } if ( 402 === $code ) { error_log( 'PSA: OpenRouter insufficient credits (402)' ); return new WP_Error( 'insufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai.' ); } $msg = $body['error']['message'] ?? ( 'Unknown error (HTTP ' . $code . ')' ); error_log( 'PSA: OpenRouter API error (' . $code . '): ' . $msg ); error_log( 'PSA: OpenRouter raw response: ' . substr( $raw_body, 0, 500 ) ); return new WP_Error( 'api_error', 'OpenRouter API error: ' . $msg ); } if ( empty( $body['choices'][0]['message']['content'] ) ) { error_log( 'PSA: OpenRouter returned empty content. Raw: ' . substr( $raw_body, 0, 500 ) ); return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' ); } // Log API call with token usage. $response_text = $body['choices'][0]['message']['content']; $prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 ); $completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 ); $token_source = 'api'; // Diagnostic: log the raw usage structure once per model so we can verify // whether the provider returns token counts. Remove after confirming. if ( 0 === $prompt_tokens ) { $usage_raw = isset( $body['usage'] ) ? wp_json_encode( $body['usage'] ) : 'missing'; error_log( 'PSA: OpenRouter usage field for model ' . $model . ': ' . substr( $usage_raw, 0, 300 ) ); } // Fallback: estimate tokens from character length when the API reports zero. // Why: Some OpenRouter models (e.g. deepseek) omit the usage object entirely. // Approximation: ~3.75 characters per token for English scientific text. if ( 0 === $prompt_tokens && 0 === $completion_tokens ) { $prompt_tokens = (int) ceil( strlen( $prompt ) / 3.75 ); $completion_tokens = (int) ceil( strlen( $response_text ) / 3.75 ); $token_source = 'estimated'; error_log( 'PSA: Token usage estimated from character length for ' . $peptide_name . ': prompt=' . $prompt_tokens . ', completion=' . $completion_tokens ); } PSA_Cost_Tracker::log_api_call( array( 'provider' => 'openrouter', 'model' => $model, 'prompt_tokens' => $prompt_tokens, 'completion_tokens' => $completion_tokens, 'request_type' => $request_type, 'peptide_name' => $peptide_name, 'success' => true, 'token_source' => $token_source, ) ); return $response_text; } /** * Parse AI response text into structured data. * * Strips thinking tags (e.g., DeepSeek) and markdown code fences, * then decodes the JSON response. * * @param string $response_text Raw response text from OpenRouter. * @return array|WP_Error Parsed data or error. */ public static function parse_response( string $response_text ) { // Strip any ... tags (some models like DeepSeek include reasoning). $cleaned = preg_replace( '/.*?<\/think>/s', '', $response_text ); // Strip markdown code fences if present. $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $cleaned ); $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned ); $cleaned = trim( $cleaned ); $data = json_decode( $cleaned, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { error_log( 'PSA: JSON parse error: ' . json_last_error_msg() . '. Raw (first 500 chars): ' . substr( $response_text, 0, 500 ) ); return new WP_Error( 'parse_error', 'Failed to parse AI response as JSON: ' . json_last_error_msg() ); } return $data; } }