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;
}
}