get_error_code() ) { $fallback = self::fallback_model( $model ); if ( '' !== $fallback ) { $retry = self::request( $fallback, $prompt, $json_mode ); if ( ! is_wp_error( $retry ) ) { $settings = ACSAGEN_Settings::all(); $settings['model'] = $fallback; ACSAGEN_Settings::save( $settings ); return $retry; } } } return $result; } /** * Pick a different model to try once when the chosen one is refused. * * @param string $failed Model that was refused. * @return string Empty string when there is nothing to try. */ private static function fallback_model( $failed ) { foreach ( array( 'gemini-2.5-flash', 'gemini-3.6-flash', 'gemini-2.5-flash-lite' ) as $candidate ) { if ( $candidate !== $failed ) { return $candidate; } } return ''; } /** * One request to one model. * * @param string $model Model id. * @param string $prompt The prompt. * @param bool $json_mode Ask the API for a JSON reply. * @return string|WP_Error */ private static function request( $model, $prompt, $json_mode ) { $api_key = ACSAGEN_Settings::api_key(); if ( '' === $api_key ) { return new WP_Error( 'acsagen_no_api_key', __( 'No Gemini API key is saved. Open Article Generator > Settings and add your own key.', 'acs-article-generator' ) ); } $config = array( 'temperature' => 0.7, 'maxOutputTokens' => self::MAX_OUTPUT_TOKENS, ); if ( $json_mode ) { $config['responseMimeType'] = 'application/json'; } $body = wp_json_encode( array( 'contents' => array( array( 'parts' => array( array( 'text' => $prompt ), ), ), ), 'generationConfig' => $config, ) ); // The key travels in a request header, never in the URL, so it does not // end up in proxy logs or in the WordPress HTTP debug output. $response = wp_remote_post( self::ENDPOINT . rawurlencode( $model ) . ':generateContent', array( 'headers' => array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $api_key, ), 'body' => $body, 'timeout' => self::TIMEOUT, ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'acsagen_request_failed', sprintf( /* translators: %s: error message returned by WordPress. */ __( 'The request to the Gemini API failed: %s', 'acs-article-generator' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); if ( strlen( $raw ) > self::MAX_RESPONSE_BYTES ) { return new WP_Error( 'acsagen_oversized_response', __( 'The reply from the Gemini API was far larger than a single article can be, so it was discarded. Please try again.', 'acs-article-generator' ) ); } $data = json_decode( $raw, true ); if ( 200 !== $code ) { $message = isset( $data['error']['message'] ) ? (string) $data['error']['message'] : ''; $status = isset( $data['error']['status'] ) ? (string) $data['error']['status'] : ''; if ( self::is_model_rejection( $code, $status, $message ) ) { return new WP_Error( 'acsagen_model_rejected', sprintf( /* translators: 1: model name, 2: message returned by the API. */ __( 'Your API key cannot use the model %1$s. Choose another model on the settings screen. (%2$s)', 'acs-article-generator' ), $model, $message ) ); } return new WP_Error( 'acsagen_api_error', sprintf( /* translators: 1: HTTP status code, 2: message returned by the API. */ __( 'The Gemini API returned an error (HTTP %1$d): %2$s', 'acs-article-generator' ), $code, '' !== $message ? $message : __( 'no further detail was given.', 'acs-article-generator' ) ) ); } $blocked = isset( $data['promptFeedback']['blockReason'] ) ? (string) $data['promptFeedback']['blockReason'] : ''; if ( '' !== $blocked ) { return new WP_Error( 'acsagen_blocked', self::safety_message( $blocked ) ); } $finish = isset( $data['candidates'][0]['finishReason'] ) ? strtoupper( (string) $data['candidates'][0]['finishReason'] ) : ''; if ( '' !== $finish && 'STOP' !== $finish ) { if ( 'MAX_TOKENS' === $finish ) { return new WP_Error( 'acsagen_max_tokens', __( 'The model hit its output limit and the reply was cut off. Choose a shorter article length and try again.', 'acs-article-generator' ) ); } if ( in_array( $finish, array( 'SAFETY', 'BLOCKLIST', 'PROHIBITED_CONTENT', 'SPII' ), true ) ) { return new WP_Error( 'acsagen_blocked', self::safety_message( $finish ) ); } return new WP_Error( 'acsagen_interrupted', sprintf( /* translators: %s: reason reported by the API. */ __( 'The model stopped before it finished (%s). Running the same request again usually works.', 'acs-article-generator' ), $finish ) ); } $text = ''; $parts = isset( $data['candidates'][0]['content']['parts'] ) ? $data['candidates'][0]['content']['parts'] : array(); if ( is_array( $parts ) ) { foreach ( $parts as $part ) { if ( isset( $part['text'] ) && empty( $part['thought'] ) ) { $text .= (string) $part['text']; } } } $text = trim( $text ); if ( '' === $text ) { return new WP_Error( 'acsagen_empty_response', __( 'The Gemini API returned an empty reply. Please try again.', 'acs-article-generator' ) ); } return $text; } /** * Whether an error response means "this key may not use this model". * * @param int $code HTTP status code. * @param string $status Status string from the API. * @param string $message Message from the API. * @return bool */ private static function is_model_rejection( $code, $status, $message ) { if ( 404 === (int) $code || 'NOT_FOUND' === $status ) { return true; } if ( ( 403 === (int) $code || 'PERMISSION_DENIED' === $status ) && false !== stripos( $message, 'model' ) ) { return true; } foreach ( array( 'no longer available', 'not available to new users', 'not available for new users' ) as $needle ) { if ( false !== stripos( $message, $needle ) ) { return true; } } return false; } /** * Wording used when the model refuses to answer. * * @param string $reason Reason reported by the API. * @return string */ private static function safety_message( $reason ) { return sprintf( /* translators: %s: reason reported by the API. */ __( 'The model stopped because of its own content filter (%s). Try rewording the topic.', 'acs-article-generator' ), $reason ); } /** * Whether an error is worth retrying with the same input. * * @param mixed $error Expected to be a WP_Error. * @return bool */ public static function is_retryable( $error ) { if ( ! is_wp_error( $error ) ) { return false; } return in_array( $error->get_error_code(), array( 'acsagen_interrupted', 'acsagen_empty_response', 'acsagen_json_truncated', 'acsagen_json_error' ), true ); } /** * Whether an error means "the reply was too long". * * @param mixed $error Expected to be a WP_Error. * @return bool */ public static function is_length_error( $error ) { if ( ! is_wp_error( $error ) ) { return false; } return in_array( $error->get_error_code(), array( 'acsagen_max_tokens', 'acsagen_json_truncated' ), true ); } // ----------------------------------------------------------------- // Turning the reply back into JSON. // // Models occasionally wrap JSON in code fences, add a sentence in front // of it, or leave a raw quote inside a string (class="..." inside the // article HTML is the usual culprit). The helpers below recover from // those cases instead of failing the whole run. // ----------------------------------------------------------------- /** * Decode the JSON object contained in a model reply. * * @param string $text Raw reply. * @return array|WP_Error */ public static function decode_json( $text ) { $had_fence = ( false !== strpos( $text, '```' ) ); $json = self::strip_wrapping_fence( $text ); $decoded = json_decode( $json, true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { return $decoded; } $json = self::strip_inner_fences( $json ); $scan = self::scan_first_object( $json ); if ( $scan['found'] && $scan['truncated'] ) { $decoded = self::repair_and_decode( $json ); if ( null !== $decoded ) { return $decoded; } return new WP_Error( 'acsagen_json_truncated', __( 'The reply was cut off before the model finished writing it. Choose a shorter article length and try again.', 'acs-article-generator' ) ); } $candidates = array(); if ( $scan['found'] ) { $candidates[] = $scan['json']; } $candidates[] = $json; foreach ( $candidates as $candidate ) { if ( '' === $candidate ) { continue; } $decoded = json_decode( $candidate, true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { return $decoded; } $decoded = self::repair_and_decode( $candidate ); if ( null !== $decoded ) { return $decoded; } } return new WP_Error( 'acsagen_json_error', $had_fence ? __( 'The reply was not valid JSON: the model wrapped it in a code block that could not be unwrapped. Please try again.', 'acs-article-generator' ) : __( 'The reply was not valid JSON. Please try again.', 'acs-article-generator' ) ); } /** * Find the first complete top level JSON object in a string. * * String literals and escapes are tracked so that braces inside the * article HTML do not throw off the count. * * @param string $text Text to scan. * @return array{found:bool,truncated:bool,json:string} */ private static function scan_first_object( $text ) { $len = strlen( $text ); $start = -1; $depth = 0; $in_string = false; $escaped = false; for ( $i = 0; $i < $len; $i++ ) { $char = $text[ $i ]; if ( $in_string ) { if ( $escaped ) { $escaped = false; continue; } if ( '\\' === $char ) { $escaped = true; continue; } if ( '"' === $char ) { $in_string = false; } continue; } if ( '"' === $char ) { if ( -1 === $start ) { continue; } $in_string = true; continue; } if ( '{' === $char ) { if ( -1 === $start ) { $start = $i; } ++$depth; continue; } if ( '}' === $char && -1 !== $start ) { --$depth; if ( 0 === $depth ) { return array( 'found' => true, 'truncated' => false, 'json' => substr( $text, $start, $i - $start + 1 ), ); } } } if ( -1 !== $start ) { return array( 'found' => true, 'truncated' => true, 'json' => substr( $text, $start ), ); } return array( 'found' => false, 'truncated' => false, 'json' => '', ); } /** * Repair a broken JSON string and decode it. * * @param string $json Text to repair. * @return array|null Null when it still cannot be decoded. */ private static function repair_and_decode( $json ) { $repaired = self::repair_strings( $json ); if ( $repaired === $json ) { return null; } $decoded = json_decode( $repaired, true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { return $decoded; } $rescan = self::scan_first_object( $repaired ); if ( $rescan['found'] && ! $rescan['truncated'] ) { $decoded = json_decode( $rescan['json'], true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { return $decoded; } } return null; } /** * Walk the string literals of a JSON document and fix what breaks decoding. * * Raw control characters become escapes, invalid escapes keep their * backslash as a literal, and a bare double quote that cannot be a string * terminator is escaped. Text outside string literals is left alone. * * @param string $json Text to repair. * @return string */ private static function repair_strings( $json ) { $out = ''; $len = strlen( $json ); $in_string = false; $is_key = false; $stack = array(); $expect_key = false; for ( $i = 0; $i < $len; $i++ ) { $char = $json[ $i ]; if ( ! $in_string ) { if ( '"' === $char ) { $in_string = true; $is_key = ( '{' === end( $stack ) && $expect_key ); } elseif ( '{' === $char ) { $stack[] = '{'; $expect_key = true; } elseif ( '[' === $char ) { $stack[] = '['; $expect_key = false; } elseif ( '}' === $char || ']' === $char ) { array_pop( $stack ); $expect_key = false; } elseif ( ':' === $char ) { $expect_key = false; } elseif ( ',' === $char ) { $expect_key = ( '{' === end( $stack ) ); } $out .= $char; continue; } if ( '\\' === $char ) { $next = ( $i + 1 < $len ) ? $json[ $i + 1 ] : ''; if ( '' === $next ) { $out .= '\\\\'; continue; } if ( false !== strpos( '"\\/bfnrt', $next ) ) { $out .= $char . $next; ++$i; continue; } if ( 'u' === $next && preg_match( '/^[0-9a-fA-F]{4}$/', substr( $json, $i + 2, 4 ) ) ) { $out .= substr( $json, $i, 6 ); $i += 5; continue; } $out .= '\\\\'; continue; } if ( '"' === $char ) { if ( self::is_string_terminator( $json, $i, $is_key ) ) { $in_string = false; $out .= $char; } else { $out .= '\\"'; } continue; } $code = ord( $char ); if ( $code < 0x20 ) { if ( "\n" === $char ) { $out .= '\\n'; } elseif ( "\r" === $char ) { $out .= '\\r'; } elseif ( "\t" === $char ) { $out .= '\\t'; } else { $out .= sprintf( '\\u%04x', $code ); } continue; } $out .= $char; } return $out; } /** * Decide whether a quote inside a string literal really ends the string. * * A key always ends in front of a colon. A value always ends in front of a * comma or a closing bracket that is itself followed by something valid. * * @param string $json Document being scanned. * @param int $pos Position of the quote. * @param bool $is_key Whether the current literal is an object key. * @return bool */ private static function is_string_terminator( $json, $pos, $is_key ) { $next = self::next_meaningful( $json, $pos + 1 ); if ( '' === $next['char'] ) { return true; } if ( $is_key ) { return ':' === $next['char']; } if ( ',' === $next['char'] ) { $after = self::next_meaningful( $json, $next['pos'] + 1 ); if ( '' === $after['char'] ) { return true; } return false !== strpos( '"{[-tfn0123456789', $after['char'] ); } if ( '}' === $next['char'] || ']' === $next['char'] ) { $after = self::next_meaningful( $json, $next['pos'] + 1 ); return '' === $after['char'] || false !== strpos( ',}]', $after['char'] ); } return false; } /** * The first non whitespace character at or after a position. * * @param string $json Document being scanned. * @param int $from Start position. * @return array{char:string,pos:int} */ private static function next_meaningful( $json, $from ) { $len = strlen( $json ); for ( $i = $from; $i < $len; $i++ ) { if ( false === strpos( " \t\r\n", $json[ $i ] ) ) { return array( 'char' => $json[ $i ], 'pos' => $i, ); } } return array( 'char' => '', 'pos' => $len, ); } /** * Remove a byte order mark and a code fence wrapped around the whole reply. * * @param string $text Raw reply. * @return string */ private static function strip_wrapping_fence( $text ) { $text = trim( $text ); $text = (string) preg_replace( '/^\xEF\xBB\xBF/', '', $text ); $text = (string) preg_replace( '/^```[a-zA-Z0-9_+-]*[ \t]*\r?\n?/', '', $text ); $text = (string) preg_replace( '/\s*```\s*$/', '', $text ); return trim( $text ); } /** * Remove fence lines left inside the reply. Only called after a normal * decode has already failed. * * @param string $text Reply. * @return string */ private static function strip_inner_fences( $text ) { if ( false === strpos( $text, '```' ) ) { return $text; } return trim( (string) preg_replace( '/^[ \t]*```[a-zA-Z0-9_+-]*[ \t]*$/m', '', $text ) ); } }