15, 'redirection' => 0, 'headers' => array( 'Authorization' => 'Bearer ' . $key ), ) ); if ( is_wp_error( $response ) ) { /* translators: %s: network error. */ return new \WP_Error( 'turtlereport_ai_network', sprintf( __( 'Couldn\'t reach OpenAI from this server: %s', 'turtlereport' ), self::clean( $response->get_error_message() ) ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $data = HttpProvider::error_data( is_array( $data ) ? $data : array() ); if ( 200 !== $code || isset( $data['error'] ) ) { return self::error( $code, $data, '' ); } $ids = array(); foreach ( isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : array() as $m ) { $id = is_array( $m ) && isset( $m['id'] ) && is_string( $m['id'] ) ? $m['id'] : ''; if ( preg_match( '/^(gpt-|o\d|chatgpt-)/', $id ) && ! preg_match( '/(audio|realtime|tts|transcribe|image|search|instruct|embedding|codex|moderation)/', $id ) ) { $ids[] = $id; } } $ids = array_values( array_unique( $ids ) ); sort( $ids, SORT_NATURAL ); return $ids; } /** * Whether a model reasons before answering (and takes the "reasoning" option). * * @param string $model Model ID. * @return bool */ public static function reasons( $model ) { return (bool) preg_match( '/^(gpt-5|gpt-6|o\d)/', (string) $model ); } /** * The request body. * * @param string $model Model. * @param string $instructions Instructions. * @param array[] $messages Turns. * @param int $max_tokens Longest answer. * @return array */ public static function body( $model, $instructions, array $messages, $max_tokens ) { $input = array(); foreach ( $messages as $m ) { $input[] = array( 'role' => 'assistant' === $m['role'] ? 'assistant' : 'user', 'content' => (string) $m['text'], ); } $body = array( 'model' => (string) $model, 'instructions' => (string) $instructions, 'input' => $input, 'max_output_tokens' => (int) $max_tokens, 'store' => false, ); if ( self::reasons( $model ) ) { // Reasoning tokens count towards max_output_tokens; keep them small so the answer fits. $body['reasoning'] = array( 'effort' => 'low' ); $body['max_output_tokens'] += 2000; } return $body; } /** Automatic retries for temporary problems (rate limits, OpenAI server errors). */ const RETRIES = 2; /** Longest single wait before a retry, in seconds. A longer Retry-After isn't waited for. */ const MAX_WAIT = 8; /** Longest total waiting across retries, in seconds. */ const MAX_TOTAL_WAIT = 12; /** * How to wait between retries (tests replace it). * * @var callable|null */ public static $sleep = null; /** * {@inheritDoc} * * Temporary rate limits and OpenAI server errors are retried up to twice, * after a short wait (OpenAI's Retry-After when it gives one, capped). * Exhausted credit, bad keys, unknown models and oversized requests are * never retried. Every result carries safe diagnostics: HTTP status, * OpenAI's error code, request ID, attempts, time. Never the key or data. * * @param string $key API key. * @param string $model Model ID. * @param string $instructions Instructions. * @param array[] $messages Turns. * @param int $max_tokens Longest answer. * @return array|\WP_Error */ public function ask( $key, $model, $instructions, array $messages, $max_tokens ) { $start = microtime( true ); $attempt = 0; $waited = 0.0; $reasoning = self::reasons( $model ); while ( true ) { ++$attempt; $body = self::body( $model, $instructions, $messages, $max_tokens ); if ( ! $reasoning ) { unset( $body['reasoning'] ); } $response = wp_remote_post( self::URL, array( 'timeout' => 90, 'redirection' => 0, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); $diag = array( 'attempts' => $attempt, 'waited' => round( $waited, 1 ), 'ms' => (int) round( ( microtime( true ) - $start ) * 1000 ), ); if ( is_wp_error( $response ) ) { $said = self::clean( $response->get_error_message() ); $late = false !== stripos( $said, 'timed out' ) || false !== stripos( $said, 'timeout' ); return new \WP_Error( $late ? 'turtlereport_ai_timeout' : 'turtlereport_ai_network', $late ? __( 'OpenAI took too long to answer. Try again, or ask about one client for a smaller question.', 'turtlereport' ) /* translators: %s: network error. */ : sprintf( __( 'Couldn\'t reach OpenAI from this server: %s', 'turtlereport' ), $said ), array( 'status' => 504, 'diagnostic' => array_merge( $diag, array( 'kind' => $late ? 'timeout' : 'network', 'http' => 0, ) ), ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $data = HttpProvider::error_data( is_array( $data ) ? $data : array() ); $rid = Text::substr( preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) wp_remote_retrieve_header( $response, 'x-request-id' ) ), 0, 64 ); $diag = array_merge( $diag, array( 'http' => $code, 'request_id' => $rid, ) ); if ( 200 === $code ) { if ( isset( $data['error'] ) ) { return self::error( $code, $data, $model, array_merge( $diag, array( 'kind' => 'bad_request' ) ) ); } break; } $kind = self::classify( $code, $data ); // A model that doesn't take the reasoning option: drop it once and try again. if ( 'bad_request' === $kind && $reasoning && self::about_reasoning( $data ) ) { $reasoning = false; continue; } if ( in_array( $kind, array( 'rate_limit', 'server' ), true ) && $attempt <= self::RETRIES ) { $wait = self::wait( $response, $attempt ); if ( null !== $wait && $waited + $wait <= self::MAX_TOTAL_WAIT ) { self::pause( $wait ); $waited += $wait; continue; } } return self::error( $code, $data, $model, array_merge( $diag, array( 'kind' => $kind ) ) ); } $text = self::text( $data ); $diag = array_merge( $diag, array( 'kind' => 'ok' ) ); if ( '' === trim( $text ) ) { if ( isset( $data['incomplete_details']['reason'] ) && 'max_output_tokens' === $data['incomplete_details']['reason'] ) { return new \WP_Error( 'turtlereport_ai_short', __( 'The answer ran out of room before it was finished. Raise "Longest answer" in Settings → AI Advisor, or ask a narrower question.', 'turtlereport' ), array( 'status' => 502, 'diagnostic' => array_merge( $diag, array( 'kind' => 'too_short' ) ), ) ); } return new \WP_Error( 'turtlereport_ai_empty', __( 'OpenAI sent back an empty answer. Try again.', 'turtlereport' ), array( 'status' => 502, 'diagnostic' => array_merge( $diag, array( 'kind' => 'empty' ) ), ) ); } return array( 'text' => $text, 'in' => isset( $data['usage']['input_tokens'] ) ? (int) $data['usage']['input_tokens'] : 0, 'out' => isset( $data['usage']['output_tokens'] ) ? (int) $data['usage']['output_tokens'] : 0, 'meta' => $diag, ); } /** * What kind of failure an error reply is. * * @param int $code HTTP status. * @param array $data Decoded reply. * @return string auth|quota|rate_limit|too_large|model|region|permission|server|bad_request */ public static function classify( $code, array $data ) { $err = isset( $data['error'] ) && is_array( $data['error'] ) ? $data['error'] : array(); $ec = strtolower( isset( $err['code'] ) ? (string) $err['code'] : '' ); $type = strtolower( isset( $err['type'] ) ? (string) $err['type'] : '' ); $msg = strtolower( isset( $err['message'] ) ? (string) $err['message'] : '' ); if ( 401 === $code || in_array( $ec, array( 'invalid_api_key', 'invalid_authentication' ), true ) ) { return 'auth'; } if ( in_array( $ec, array( 'insufficient_quota', 'billing_hard_limit_reached', 'billing_not_active' ), true ) || 'insufficient_quota' === $type || false !== strpos( $msg, 'exceeded your current quota' ) ) { return 'quota'; } if ( 'context_length_exceeded' === $ec || false !== strpos( $msg, 'request too large' ) || false !== strpos( $msg, 'maximum context length' ) ) { return 'too_large'; } if ( 429 === $code || 'rate_limit_exceeded' === $ec || 'rate_limit_error' === $type ) { return 'rate_limit'; } if ( 404 === $code || 'model_not_found' === $ec || ( false !== strpos( $msg, 'model' ) && false !== strpos( $msg, 'does not exist' ) ) ) { return 'model'; } if ( 'unsupported_country_region_territory' === $ec ) { return 'region'; } if ( 403 === $code ) { return 'permission'; } if ( $code >= 500 ) { return 'server'; } return 'bad_request'; } /** * Whether a 400 is about the "reasoning" option. * * @param array $data Decoded reply. * @return bool */ private static function about_reasoning( array $data ) { $param = isset( $data['error']['param'] ) ? (string) $data['error']['param'] : ''; $msg = isset( $data['error']['message'] ) ? (string) $data['error']['message'] : ''; return 0 === strpos( $param, 'reasoning' ) || false !== stripos( $msg, 'reasoning' ); } /** * Seconds to wait before retry number $attempt, or null when OpenAI asks for longer than we wait. * * @param array|\WP_Error $response Response. * @param int $attempt Attempt that failed (1-based). * @return float|null */ public static function wait( $response, $attempt ) { $ms = (string) wp_remote_retrieve_header( $response, 'retry-after-ms' ); $sec = (string) wp_remote_retrieve_header( $response, 'retry-after' ); if ( is_numeric( $ms ) ) { $asked = (float) $ms / 1000; } elseif ( is_numeric( $sec ) ) { $asked = (float) $sec; } else { $asked = null; } if ( null !== $asked ) { return $asked > self::MAX_WAIT ? null : max( 0.5, $asked ); } // No hint: 1.5s, then 3s. return min( self::MAX_WAIT, 1.5 * pow( 2, $attempt - 1 ) ); } /** * Waits. * * @param float $seconds Seconds. * @return void */ private static function pause( $seconds ) { if ( is_callable( self::$sleep ) ) { call_user_func( self::$sleep, $seconds ); return; } usleep( (int) round( $seconds * 1000000 ) ); } /** * The answer's text from a Responses API reply. * * @param array $data Decoded reply. * @return string */ public static function text( array $data ) { if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) { return $data['output_text']; } $parts = array(); foreach ( isset( $data['output'] ) && is_array( $data['output'] ) ? $data['output'] : array() as $item ) { if ( ! is_array( $item ) || ( isset( $item['type'] ) && 'message' !== $item['type'] ) || empty( $item['content'] ) || ! is_array( $item['content'] ) ) { continue; } foreach ( $item['content'] as $c ) { if ( is_array( $c ) && isset( $c['text'] ) && is_string( $c['text'] ) && ( ! isset( $c['type'] ) || 'output_text' === $c['type'] ) ) { $parts[] = (string) $c['text']; } } } return implode( "\n\n", $parts ); } /** * A message for an error reply. Never repeats the key. * * @param int $code HTTP status. * @param array $data Decoded reply. * @param string $model Model. * @param array $diag Diagnostics (kind, attempts…). * @return \WP_Error */ public static function error( $code, array $data, $model, array $diag = array() ) { $kind = isset( $diag['kind'] ) ? (string) $diag['kind'] : self::classify( $code, $data ); $said = isset( $data['error']['message'] ) ? self::clean( (string) $data['error']['message'] ) : ''; $ec = isset( $data['error']['code'] ) ? (string) $data['error']['code'] : ( isset( $data['error']['type'] ) ? (string) $data['error']['type'] : '' ); $tries = isset( $diag['attempts'] ) ? (int) $diag['attempts'] : 1; $status = 502; switch ( $kind ) { case 'auth': $msg = __( 'OpenAI didn\'t accept the API key: it\'s wrong, was revoked, or belongs to a deleted project. Paste a new key in Settings → AI Advisor.', 'turtlereport' ); break; case 'quota': $msg = __( 'Your OpenAI account has no credit left or reached its spending limit, so this question wasn\'t answered. Add credit or raise the limit at platform.openai.com → Billing. TurtleReport doesn\'t retry this automatically.', 'turtlereport' ); break; case 'rate_limit': $status = 429; $msg = $tries > 1 /* translators: %d: number of attempts. */ ? sprintf( __( 'OpenAI\'s rate limit for this API key was reached (too many requests or tokens per minute). TurtleReport tried %d times. Wait a minute and ask again; if it keeps happening, raise your limits at platform.openai.com → Limits.', 'turtlereport' ), $tries ) : __( 'OpenAI\'s rate limit for this API key was reached (too many requests or tokens per minute) and it asked to wait longer than TurtleReport waits. Ask again in a minute.', 'turtlereport' ); break; case 'too_large': $msg = __( 'This question needs more data than OpenAI accepts in one request for this key. Ask about one client, or choose a shorter date range.', 'turtlereport' ); break; case 'model': /* translators: %s: model ID. */ $msg = sprintf( __( 'The model “%s” isn\'t available to this API key: it doesn\'t exist, or the project can\'t use it. Pick another model in Settings → AI Advisor, for example gpt-5-mini.', 'turtlereport' ), $model ); break; case 'region': $msg = __( 'OpenAI doesn\'t accept requests from the country or region this server is in.', 'turtlereport' ); break; case 'permission': $msg = __( 'This API key isn\'t allowed to do this (its project or permissions are restricted). Check the key\'s permissions at platform.openai.com → API keys.', 'turtlereport' ); break; case 'server': /* translators: 1: HTTP status, 2: number of attempts. */ $msg = sprintf( __( 'OpenAI is having problems right now (error %1$d, tried %2$d times). Try again in a few minutes.', 'turtlereport' ), $code, $tries ); break; default: /* translators: %s: OpenAI's explanation. */ $msg = '' !== $said ? sprintf( __( 'OpenAI couldn\'t answer: %s', 'turtlereport' ), $said ) : __( 'OpenAI couldn\'t answer this request.', 'turtlereport' ); } $names = array( 'auth' => 'invalid_key', 'quota' => 'no_credit', 'rate_limit' => 'rate_limit', 'too_large' => 'too_large', 'model' => 'model', 'region' => 'region', 'permission' => 'permission', 'server' => 'unavailable', 'bad_request' => 'bad_request', ); return new \WP_Error( 'turtlereport_ai_' . ( isset( $names[ $kind ] ) ? $names[ $kind ] : 'bad_request' ), $msg, array( 'status' => $status, 'diagnostic' => array_merge( $diag, array( 'kind' => $kind, 'http' => (int) $code, 'code' => Text::substr( preg_replace( '/[^a-z0-9_\.\-]/i', '', $ec ), 0, 60 ), ) ), ) ); } /** * Strips anything that looks like a key from a message, and shortens it. * * @param string $text Text. * @return string */ public static function clean( $text ) { $text = (string) preg_replace( '/\b(sk|rk|pk)-[A-Za-z0-9_\-*]{6,}/', '[key hidden]', (string) $text ); return Text::truncate( wp_strip_all_tags( $text ), 240 ); } }