request( $key, $model, $instructions, $messages, $max_tokens ); $response = wp_remote_post( $req['url'], array( 'timeout' => 90, 'redirection' => 0, 'headers' => array_merge( array( 'Content-Type' => 'application/json' ), $req['headers'] ), 'body' => wp_json_encode( $req['body'] ), ) ); $diag = array( 'attempts' => $attempt, 'waited' => round( $waited, 1 ), 'ms' => (int) round( ( microtime( true ) - $start ) * 1000 ), ); if ( is_wp_error( $response ) ) { return $this->network_error( $response, $diag ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $data = self::error_data( is_array( $data ) ? $data : array() ); $rid = Text::substr( preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) wp_remote_retrieve_header( $response, 'request-id' ) . (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 $this->error( $code, $data, $model, array_merge( $diag, array( 'kind' => 'bad_request' ) ) ); } break; } $kind = $this->classify( $code, $data ); 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 $this->error( $code, $data, $model, array_merge( $diag, array( 'kind' => $kind ) ) ); } $out = $this->parse( $data ); $diag = array_merge( $diag, array( 'kind' => 'ok' ) ); if ( '' === trim( $out['text'] ) ) { if ( $out['cut'] ) { 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', /* translators: %s: AI provider name. */ sprintf( __( '%s sent back an empty answer. Try again.', 'turtlereport' ), $this->label() ), array( 'status' => 502, 'diagnostic' => array_merge( $diag, array( 'kind' => 'empty' ) ), ) ); } return array( 'text' => $out['text'], 'in' => (int) $out['in'], 'out' => (int) $out['out'], 'meta' => $diag, ); } /** * The chat models this key can use, straight from the provider. * * @param string $key API key. * @return string[]|\WP_Error */ public function list_models( $key ) { $req = $this->models_request( $key ); $response = wp_remote_get( $req['url'], array( 'timeout' => 15, 'redirection' => 0, 'headers' => $req['headers'], ) ); if ( is_wp_error( $response ) ) { return $this->network_error( $response, array() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $data = self::error_data( is_array( $data ) ? $data : array() ); if ( 200 !== $code || isset( $data['error'] ) ) { return $this->error( $code, $data, '', array( 'kind' => $this->classify( $code, $data ) ) ); } $ids = array_values( array_unique( array_filter( array_map( 'strval', $this->models_parse( $data ) ) ) ) ); sort( $ids, SORT_NATURAL ); return $ids; } /** * A network or timeout failure. * * @param \WP_Error $e Error. * @param array $diag Diagnostics. * @return \WP_Error */ private function network_error( \WP_Error $e, array $diag ) { $said = self::clean( $e->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 /* translators: %s: AI provider name. */ ? sprintf( __( '%s took too long to answer. Try again, or ask about one client for a smaller question.', 'turtlereport' ), $this->label() ) /* translators: 1: AI provider name, 2: network error. */ : sprintf( __( 'Couldn\'t reach %1$s from this server: %2$s', 'turtlereport' ), $this->label(), $said ), array( 'status' => 504, 'diagnostic' => array_merge( $diag, array( 'kind' => $late ? 'timeout' : 'network', 'http' => 0, ) ), ) ); } /** * 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 function error( $code, array $data, $model, array $diag = array() ) { $kind = isset( $diag['kind'] ) ? (string) $diag['kind'] : $this->classify( $code, $data ); $said = $this->said( $data ); $text = self::clean( $said[0] ); $name = $this->label(); $where = $this->console(); $tries = isset( $diag['attempts'] ) ? (int) $diag['attempts'] : 1; $status = 502; switch ( $kind ) { case 'auth': /* translators: %s: AI provider name. */ $msg = sprintf( __( '%s didn\'t accept the API key: it\'s wrong or was revoked. Paste a new key in Settings → AI Advisor.', 'turtlereport' ), $name ); break; case 'quota': /* translators: 1: AI provider name, 2: the provider's console address. */ $msg = sprintf( __( 'Your %1$s account has no credit left or reached its spending limit, so this question wasn\'t answered. Add credit or raise the limit at %2$s. TurtleReport doesn\'t retry this automatically.', 'turtlereport' ), $name, $where ); break; case 'rate_limit': $status = 429; /* translators: 1: AI provider name, 2: number of attempts. */ $msg = sprintf( __( '%1$s\'s rate limit for this API key was reached (too many requests or tokens per minute). TurtleReport tried %2$d times. Wait a minute and ask again.', 'turtlereport' ), $name, $tries ); break; case 'too_large': /* translators: %s: AI provider name. */ $msg = sprintf( __( 'This question needs more data than %s accepts in one request for this key. Ask about one client, or choose a shorter date range.', 'turtlereport' ), $name ); break; case 'model': /* translators: 1: model ID, 2: AI provider name, 3: a model that works. */ $msg = sprintf( __( 'The model “%1$s” isn\'t available to this %2$s API key: it doesn\'t exist, or the account can\'t use it. Pick another model in Settings → AI Advisor, for example %3$s.', 'turtlereport' ), $model, $name, $this->default_model() ); break; case 'region': /* translators: %s: AI provider name. */ $msg = sprintf( __( '%s doesn\'t accept requests from the country or region this server is in.', 'turtlereport' ), $name ); break; case 'permission': /* translators: 1: AI provider name, 2: the provider's console address. */ $msg = sprintf( __( 'This %1$s API key isn\'t allowed to do this (its permissions are restricted). Check the key at %2$s.', 'turtlereport' ), $name, $where ); break; case 'server': /* translators: 1: AI provider name, 2: HTTP status, 3: number of attempts. */ $msg = sprintf( __( '%1$s is having problems right now (error %2$d, tried %3$d times). Try again in a few minutes.', 'turtlereport' ), $name, $code, $tries ); break; default: $msg = '' !== $text /* translators: 1: AI provider name, 2: the provider's explanation. */ ? sprintf( __( '%1$s couldn\'t answer: %2$s', 'turtlereport' ), $name, $text ) /* translators: %s: AI provider name. */ : sprintf( __( '%s couldn\'t answer this request.', 'turtlereport' ), $name ); } $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', '', $said[1] ), 0, 60 ), ) ), ) ); } /** * Seconds to wait before retry number $attempt, or null when the provider 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 ) { $sec = (string) wp_remote_retrieve_header( $response, 'retry-after' ); if ( is_numeric( $sec ) ) { return (float) $sec > self::MAX_WAIT ? null : max( 0.5, (float) $sec ); } 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 ) ); } /** * Turns as user/assistant pairs. * * @param array[] $messages Turns. * @return array[] Items with role (user|assistant) and text. */ protected static function turns( array $messages ) { $out = array(); foreach ( $messages as $m ) { $out[] = array( 'role' => isset( $m['role'] ) && 'assistant' === $m['role'] ? 'assistant' : 'user', 'text' => isset( $m['text'] ) ? (string) $m['text'] : '', ); } return $out; } /** * 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-ant-|sk-|rk-|pk-|xai-)[A-Za-z0-9_\-*]{6,}/', '[key hidden]', (string) $text ); $text = (string) preg_replace( '/\bAIza[0-9A-Za-z_\-]{20,}/', '[key hidden]', $text ); $text = (string) preg_replace( '/([?&]key=)[^&\s]+/', '$1[key hidden]', $text ); return Text::truncate( wp_strip_all_tags( $text ), 240 ); } }