?". */ class Evoka_Byok_Client { /** * Option name holding the user's own API key. */ const KEY_OPTION = 'evoka_api_key'; /** * Is a key configured? * * @return bool */ public function has_key() { $key = get_option( self::KEY_OPTION, '' ); return is_string( $key ) && '' !== trim( $key ); } /** * Provider slug (openai|perplexity), or '' when none is chosen. * * @return string */ public function provider() { $provider = (string) evoka_get_setting( 'byok_provider', '' ); return in_array( $provider, array( 'openai', 'perplexity' ), true ) ? $provider : ''; } /** * Ready to run checks: a key AND a provider are configured. * * @return bool */ public function is_configured() { return $this->has_key() && '' !== $this->provider(); } /** * Does a text mention a domain (or one of its subdomains) as a whole host * name? "example.com" matches "Example.com", "www.example.com" and * "blog.example.com/post" but not "notexample.com" or "example.com.evil.net". * * @param string $text Text to search. * @param string $domain Host name. * @return bool */ public static function mentions_domain( $text, $domain ) { $domain = preg_replace( '/^www\./i', '', strtolower( trim( (string) $domain ) ) ); if ( '' === $domain ) { return false; } $pattern = '/(?has_key() ) { return new WP_Error( 'evoka_no_key', __( 'No API key configured. Add your own key in Evoka -> Settings to run live checks.', 'evoka' ) ); } if ( '' === $this->provider() ) { return new WP_Error( 'evoka_no_provider', __( 'Choose a provider (OpenAI or Perplexity) for your API key in Evoka -> Settings.', 'evoka' ) ); } $query = trim( wp_strip_all_tags( (string) $query ) ); $domain = $domain ? $domain : wp_parse_url( home_url(), PHP_URL_HOST ); if ( '' === $query ) { return new WP_Error( 'evoka_empty_query', __( 'Please enter a question to test.', 'evoka' ) ); } $result = ( 'perplexity' === $this->provider() ) ? $this->call_perplexity( $query ) : $this->call_openai( $query ); if ( is_wp_error( $result ) ) { return $result; } $cited_sources = array(); foreach ( $result['sources'] as $source ) { if ( self::mentions_domain( (string) wp_parse_url( $source, PHP_URL_HOST ), $domain ) ) { $cited_sources[] = $source; } } return array( 'cited' => self::mentions_domain( $result['answer'], $domain ) || ! empty( $cited_sources ), 'answer' => $result['answer'], 'query' => $query, 'domain' => (string) $domain, 'sources' => $result['sources'], 'cited_sources' => $cited_sources, ); } /** * Cap on the answer length. The user pays for their own key, and without * a cap some gateways reserve the model's maximum output (OpenRouter * reserved 65,536 tokens for perplexity/sonar and refused the request on * a low-credit key). A citation check needs a short answer. * * @return int */ private function max_tokens() { return max( 64, (int) apply_filters( 'evoka_max_tokens', 1024 ) ); } /** * Call OpenAI chat completions with the user's key. * * @param string $query Prompt. * @return array|WP_Error See request(). */ private function call_openai( $query ) { $body = array( 'model' => apply_filters( 'evoka_openai_model', 'gpt-4o-mini' ), 'max_tokens' => $this->max_tokens(), 'messages' => array( array( 'role' => 'user', 'content' => $query . "\n\nAnswer concisely and include the source websites you would cite.", ), ), ); return $this->request( 'https://api.openai.com/v1/chat/completions', $body, array( 'Authorization' => 'Bearer ' . $this->get_key() ) ); } /** * Call Perplexity chat completions with the user's key. * * @param string $query Prompt. * @return array|WP_Error See request(). */ private function call_perplexity( $query ) { $body = array( 'model' => apply_filters( 'evoka_perplexity_model', 'sonar' ), 'max_tokens' => $this->max_tokens(), 'messages' => array( array( 'role' => 'user', 'content' => $query, ), ), ); return $this->request( 'https://api.perplexity.ai/chat/completions', $body, array( 'Authorization' => 'Bearer ' . $this->get_key() ) ); } /** * Perform the HTTP request and extract the assistant message. * * @param string $url Endpoint. * @param array $body JSON body. * @param array $headers Extra headers. * @return array|WP_Error {answer:string, sources:string[]} */ private function request( $url, $body, $headers ) { $response = wp_remote_post( $url, array( 'timeout' => 30, 'headers' => array_merge( array( 'Content-Type' => 'application/json' ), $headers ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { $message = isset( $data['error']['message'] ) ? $data['error']['message'] : sprintf( 'HTTP %d', $code ); return new WP_Error( 'evoka_api_error', $message ); } if ( isset( $data['choices'][0]['message']['content'] ) ) { return array( 'answer' => (string) $data['choices'][0]['message']['content'], 'sources' => $this->extract_sources( $data ), ); } return new WP_Error( 'evoka_bad_response', __( 'Unexpected response from the provider.', 'evoka' ) ); } /** * Source URLs returned alongside the answer. Perplexity returns * `citations` (URL strings) and/or `search_results` ({url, ...}); OpenAI * web-search models and OpenAI-compatible gateways (e.g. OpenRouter) * return `message.annotations` entries of type `url_citation`. Plain * OpenAI chat completions return none. * * @param array $data Decoded response. * @return array Unique http(s) URLs (max 20). */ private function extract_sources( $data ) { $urls = array(); if ( ! empty( $data['citations'] ) && is_array( $data['citations'] ) ) { foreach ( $data['citations'] as $c ) { if ( is_string( $c ) ) { $urls[] = $c; } } } if ( ! empty( $data['search_results'] ) && is_array( $data['search_results'] ) ) { foreach ( $data['search_results'] as $r ) { if ( is_array( $r ) && ! empty( $r['url'] ) && is_string( $r['url'] ) ) { $urls[] = $r['url']; } } } if ( ! empty( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) { foreach ( $data['choices'][0]['message']['annotations'] as $a ) { if ( is_array( $a ) && isset( $a['type'], $a['url_citation']['url'] ) && 'url_citation' === $a['type'] && is_string( $a['url_citation']['url'] ) ) { $urls[] = $a['url_citation']['url']; } } } $urls = array_filter( array_map( 'esc_url_raw', $urls ), function ( $u ) { return (bool) preg_match( '#^https?://#i', $u ); } ); return array_slice( array_values( array_unique( $urls ) ), 0, 20 ); } /** * Read the user's key. * * @return string */ private function get_key() { return (string) get_option( self::KEY_OPTION, '' ); } }