string, 'message' => string, 'status' => int|null ]. * * @since 1.27.0 * @var array|null */ private $last_error = null; /** * Toggle smart-embed path on this client instance. When true, * `get_embedding` and `get_embeddings_batch` POST to /smart-embed * instead of /embeddings. Flipped per-call by the AI_Search caller * so the search path (query embedding) stays on the fast /embeddings * route even when Smart embeddings are enabled site-wide. * * @since 1.43.0 * @var bool */ private $smart_mode = false; public function __construct() { // Routed through our global CDN. Overridable via the // AI_SEARCH_SERVICE_URL constant for self-hosted / on-prem. $this->service_url = defined( 'AI_SEARCH_SERVICE_URL' ) ? trailingslashit( AI_SEARCH_SERVICE_URL ) : 'https://ai-search-proxy.ancient-shadow-2a64.workers.dev/'; } /** * Flip the next embedding call(s) to use /smart-embed. Idempotent — * callers that need the regular path simply pass false (or omit it). * * @since 1.43.0 * @param bool $on * @return self */ public function set_smart_mode( $on ) { $this->smart_mode = (bool) $on; return $this; } /** * Register the current site origin and store token. */ public function register_origin() { // v1.44.1 security: the registration call previously carried a // hardcoded placeholder `Authorization: Bearer your-secret-token` // header — a dev-time string that shipped to production. The // service identifies registering clients by the `Origin` header, // so the placeholder was both pointless and a bad-hygiene signal // visible to anyone decompiling the plugin. $response = wp_remote_post( $this->service_url . 'clients/register-origin', [ 'headers' => [ 'Origin' => get_site_url(), ], 'timeout' => 20, ]); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( isset( $body['api_token'] ) ) { update_option( 'ai_search_service_token', sanitize_text_field( $body['api_token'] ) ); // Store the registered origin for mismatch detection update_option( 'ai_search_registered_origin', get_site_url() ); return $body['api_token']; } return false; } /** * Check if the current site URL matches the registered origin. * * @return bool True if origins match or no token exists, false if mismatch */ public function check_origin_match() { $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { return true; // No token, no mismatch } $registered_origin = get_option( 'ai_search_registered_origin', '' ); if ( empty( $registered_origin ) ) { // Legacy: token exists but no stored origin, validate via API $validation = $this->validate_token(); if ( $validation && isset( $validation['origin'] ) ) { update_option( 'ai_search_registered_origin', $validation['origin'] ); $registered_origin = $validation['origin']; } else { return true; // Can't determine, assume OK } } $current_origin = get_site_url(); // Normalize URLs for comparison (remove trailing slashes, compare hosts) $registered_host = wp_parse_url( $registered_origin, PHP_URL_HOST ); $current_host = wp_parse_url( $current_origin, PHP_URL_HOST ); return $registered_host === $current_host; } /** * Get the registered origin. * * @return string The registered origin URL */ public function get_registered_origin() { return get_option( 'ai_search_registered_origin', '' ); } /** * Recover a fresh API token for the current site origin. * * Used when the service reports the existing token is missing from its * database (e.g. a DB reset on the service side). The service rotates * the token in place keeping all quotas intact, so we just swap it * locally and bust the validation/quota caches that were keyed off the * old token. * * @return bool True on success, false otherwise. */ public function recover_token() { $response = wp_remote_post( $this->service_url . 'clients/recover-token', [ 'headers' => [ 'Origin' => get_site_url(), ], 'timeout' => 20, ]); if ( is_wp_error( $response ) ) { error_log( 'AI Search: recover_token failed - ' . $response->get_error_message() ); return false; } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code !== 200 || empty( $body['api_token'] ) ) { $error = isset( $body['error'] ) ? $body['error'] : 'unknown error'; error_log( 'AI Search: recover_token returned ' . $status_code . ' - ' . $error ); return false; } update_option( 'ai_search_service_token', sanitize_text_field( $body['api_token'] ) ); update_option( 'ai_search_registered_origin', get_site_url() ); // Old caches were keyed off the previous (now-invalid) token; clear // them so the next "Check connection" round-trip is fresh. delete_transient( 'ai_search_validation_data' ); delete_transient( 'ai_search_validation_error' ); delete_transient( 'ai_search_quota_data' ); delete_transient( 'ai_search_quota_api_error' ); return true; } /** * Get embedding from AI Search Service with local transient cache. * * @param string $content * @param int $timeout Seconds the HTTP call may take before we give up. * Default 20 (publish / bulk path). Search path * passes 3 so a slow service never blocks a * shopper's search (v1.43.3). * @return array|false { embedding => [...], id => ... } or false */ public function get_embedding( $content, $timeout = 20 ) { $this->last_error = null; $cache_key = 'ai_service_embedding_' . md5( $content ); $cached = get_transient( $cache_key ); if ( $cached !== false ) { return $cached; } $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { $this->set_error( 'no_token', __( 'The site is not connected to the AI Search Service (no token). Open the General tab and run the connection check.', 'ai-search' ) ); return false; } // v1.43.0: Smart embeddings opt-in — same payload shape, different // endpoint. Service's /smart-embed enriches via LLM before embedding // (Standard tier). Fall back to /embeddings path is handled by the // caller when $use_smart is already resolved to false. $use_smart = ! empty( $this->smart_mode ); $endpoint = $use_smart ? 'smart-embed' : 'embeddings'; $response = wp_remote_post( $this->service_url . $endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $service_token, 'Origin' => get_site_url(), 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'texts' => [ $content ] ]), 'timeout' => (int) $timeout, ]); if ( is_wp_error( $response ) ) { $this->set_error( 'http_error', sprintf( /* translators: %s: transport error message (e.g. cURL error 28) */ __( 'Could not reach the AI Search Service: %s', 'ai-search' ), $response->get_error_message() ) ); return false; } $status_code = (int) wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $body = json_decode( $response_body, true ); if ( $status_code !== 200 ) { $this->set_error( self::error_code_for_status( $status_code ), self::extract_error_message( $body, $status_code ), $status_code ); return false; } if ( isset( $body['embeddings'][0]['embedding'] ) ) { $embedding = $body['embeddings'][0]['embedding']; // Check if embedding is a string that needs decoding or already an array if ( is_string( $embedding ) ) { $result = json_decode( $embedding, true ); } else { $result = $embedding; } if ( ! is_array( $result ) || empty( $result ) ) { $this->set_error( 'invalid_response', __( 'The AI Search Service returned an empty embedding.', 'ai-search' ), $status_code ); return false; } // Prepare the return value with both embedding and id/index $return_value = [ 'embedding' => $result, 'id' => isset( $body['embeddings'][0]['id'] ) ? $body['embeddings'][0]['id'] : ( isset( $body['embeddings'][0]['index'] ) ? $body['embeddings'][0]['index'] : 0 ) ]; set_transient( $cache_key, $return_value, MONTH_IN_SECONDS ); return $return_value; } $this->set_error( 'invalid_response', __( 'The AI Search Service response did not contain an embedding.', 'ai-search' ), $status_code ); return false; } /** * Batch version of get_embedding(). * * Sends every un-cached text in a single POST /embeddings request * rather than one HTTP round-trip per text — huge win when the * Indexing tab is syncing a catalogue of hundreds of posts, where the * network cost used to dominate wall-clock time. * * Per-text transient cache is checked first (same key as the singular * path — `ai_service_embedding_`), so overlapping content between * batches doesn't pay the OpenAI cost twice. * * @since 1.42.0 * * @param string[] $texts Texts to embed, indexed by numeric position. * @return array|false Map input_index => embedding vector, * or false on any failure. On failure * last_error is set. Successful cache * hits are always included; if the * network call for the misses fails, * the whole call returns false so the * caller can uniformly report an error * for every post in its batch (a * partial map would be misleading). */ public function get_embeddings_batch( array $texts ) { $this->last_error = null; if ( empty( $texts ) ) { return []; } // Phase 1: pull whatever is already in the transient cache. Same // key scheme as get_embedding() so the singular path benefits from // the same cache and vice versa. $out = []; $to_fetch = []; foreach ( $texts as $i => $t ) { $t = (string) $t; if ( $t === '' ) { continue; } $cache_key = 'ai_service_embedding_' . md5( $t ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) && ! empty( $cached['embedding'] ) && is_array( $cached['embedding'] ) ) { $out[ $i ] = $cached['embedding']; continue; } $to_fetch[ $i ] = $t; } if ( empty( $to_fetch ) ) { return $out; } $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { $this->set_error( 'no_token', __( 'The site is not connected to the AI Search Service (no token). Open the General tab and run the connection check.', 'ai-search' ) ); return false; } // 120s flat. Since v1.46.1 the service's /embeddings route batches // all misses into a single OpenAI call (was ~1-2s for 20 texts), so // the old per-text scaling formula is no longer needed. Keeping a // generous ceiling covers Smart Matching enrichment (LLM per text) // and any OpenAI slow-day burst; still bounded so a hung connection // can't hold a PHP-FPM worker forever. Filterable for operators // whose host sets a lower max_execution_time. $timeout = (int) apply_filters( 'ai_search_embeddings_batch_timeout', 120, count( $to_fetch ) ); // v1.43.0: Smart embeddings opt-in — swap endpoint when the caller // set smart_mode. Payload and response shapes are identical, so // nothing else in this method changes. $endpoint = $this->smart_mode ? 'smart-embed' : 'embeddings'; $response = wp_remote_post( $this->service_url . $endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $service_token, 'Origin' => get_site_url(), 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'texts' => array_values( $to_fetch ) ] ), 'timeout' => $timeout, ] ); if ( is_wp_error( $response ) ) { $this->set_error( 'http_error', sprintf( /* translators: %s: transport error message (e.g. cURL error 28) */ __( 'Could not reach the AI Search Service: %s', 'ai-search' ), $response->get_error_message() ) ); return false; } $status_code = (int) wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $body = json_decode( $response_body, true ); if ( $status_code !== 200 ) { $this->set_error( self::error_code_for_status( $status_code ), self::extract_error_message( $body, $status_code ), $status_code ); return false; } if ( ! is_array( $body ) || ! isset( $body['embeddings'] ) || ! is_array( $body['embeddings'] ) ) { $this->set_error( 'invalid_response', __( 'The AI Search Service response did not contain embeddings.', 'ai-search' ), $status_code ); return false; } // Phase 2: map every response back to the caller's original index. // The service echoes `index` as the position inside the array WE // sent it (0..N-1 across array_values($to_fetch)), so we translate // that back to the original numeric key with array_keys(). $original_keys = array_keys( $to_fetch ); foreach ( $body['embeddings'] as $item ) { if ( ! is_array( $item ) || ! isset( $item['index'] ) || ! isset( $item['embedding'] ) ) { continue; } $pos = (int) $item['index']; if ( ! isset( $original_keys[ $pos ] ) ) { continue; } $vec = $item['embedding']; if ( is_string( $vec ) ) { $vec = json_decode( $vec, true ); } if ( ! is_array( $vec ) || empty( $vec ) ) { continue; } $original_key = $original_keys[ $pos ]; $out[ $original_key ] = $vec; // Populate the singular-path cache so a later get_embedding() // for the same text is served without a round-trip. $cache_key = 'ai_service_embedding_' . md5( $to_fetch[ $original_key ] ); set_transient( $cache_key, [ 'embedding' => $vec ], MONTH_IN_SECONDS ); } return $out; } /** * Details of the last get_embedding() failure. * * @since 1.27.0 * * @return array|null [ 'code', 'message', 'status' ] or null if the last call succeeded. */ public function get_last_error() { return $this->last_error; } /** * Record (and log) an embedding failure. * * @since 1.27.0 * * @param string $code Machine-readable code. * @param string $message Human-readable message. * @param int|null $status HTTP status, if any. */ private function set_error( $code, $message, $status = null ) { $this->last_error = [ 'code' => (string) $code, 'message' => (string) $message, 'status' => $status !== null ? (int) $status : null, ]; error_log( 'AI Search: embedding failed [' . $code . ( $status ? ' HTTP ' . $status : '' ) . '] ' . $message ); } /** * Map an HTTP status from the service into a stable error code. * * @since 1.27.0 * * @param int $status HTTP status code. * @return string */ public static function error_code_for_status( $status ) { $status = (int) $status; if ( $status === 401 || $status === 403 ) { return 'invalid_token'; } if ( $status === 402 || $status === 429 ) { return 'quota_exceeded'; } if ( $status >= 500 ) { return 'service_unavailable'; } return 'service_error'; } /** * Pull a human-readable message out of a service error body. * * @since 1.27.0 * * @param mixed $body Decoded JSON body (or null). * @param int $status HTTP status code. * @return string */ public static function extract_error_message( $body, $status ) { $status = (int) $status; $detail = ''; if ( is_array( $body ) ) { foreach ( [ 'error', 'message', 'detail' ] as $key ) { if ( ! empty( $body[ $key ] ) && is_string( $body[ $key ] ) ) { $detail = $body[ $key ]; break; } if ( ! empty( $body[ $key ]['message'] ) && is_string( $body[ $key ]['message'] ) ) { $detail = $body[ $key ]['message']; break; } } } switch ( self::error_code_for_status( $status ) ) { case 'invalid_token': $base = __( 'The AI Search Service rejected this site\'s token. Use "Reconnect with new token" on the General tab.', 'ai-search' ); break; case 'quota_exceeded': $base = __( 'Embedding quota exceeded (daily or total). Check your usage on the General tab; syncing resumes when the quota resets.', 'ai-search' ); break; case 'service_unavailable': $base = __( 'The AI Search Service is temporarily unavailable. Try again in a few minutes.', 'ai-search' ); break; default: $base = __( 'The AI Search Service returned an error.', 'ai-search' ); } /* translators: 1: base message, 2: HTTP status, 3: detail from the service */ return $detail !== '' ? sprintf( __( '%1$s (HTTP %2$d: %3$s)', 'ai-search' ), $base, $status, $detail ) : sprintf( __( '%1$s (HTTP %2$d)', 'ai-search' ), $base, $status ); } /** * Validate the current service token. * * @return array|false Validation result or false on failure */ public function validate_token() { $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { return false; } $response = wp_remote_post( $this->service_url . 'clients/validate', [ 'headers' => [ 'Origin' => get_site_url(), 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'token' => $service_token, 'origin' => get_site_url() ]), 'timeout' => 20, ]); if ( is_wp_error( $response ) ) { // Capture the actual transport error so the UI can show WHY it // failed (cURL 28 timeout, 60 SSL, 6 DNS …). Without this, // operators see a generic "Unable to reach" with nothing // actionable. Cleared on the next successful validate. set_transient( 'ai_search_validation_transport_error', $response->get_error_message(), 300 ); return false; } delete_transient( 'ai_search_validation_transport_error' ); $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); return $body; } /** * Get quota information from AI Search Service. * * @return array|false Quota data or false on failure */ public function get_quota_info() { $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { $error = __( 'No service token found. Please register your site first.', 'ai-search' ); set_transient( 'ai_search_quota_api_error', $error, 60 ); error_log( 'AI Search: ' . $error ); return false; } $response = wp_remote_get( $this->service_url . 'clients/quota', [ 'headers' => [ 'Authorization' => 'Bearer ' . $service_token, 'Origin' => get_site_url(), ], 'timeout' => 20, ]); if ( is_wp_error( $response ) ) { $error = sprintf( /* translators: %s: error message */ __( 'Connection error: %s', 'ai-search' ), $response->get_error_message() ); set_transient( 'ai_search_quota_api_error', $error, 60 ); error_log( 'AI Search: Quota API error - ' . $response->get_error_message() ); return false; } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code !== 200 ) { // Try to get error message from response body $error_message = ''; if ( isset( $body['error'] ) ) { $error_message = $body['error']; } elseif ( isset( $body['message'] ) ) { $error_message = $body['message']; } else { $error_message = sprintf( /* translators: %d: HTTP status code */ __( 'API returned status code %d', 'ai-search' ), $status_code ); } $error = sprintf( /* translators: 1: HTTP status code, 2: error message */ __( 'API Error (Status %1$d): %2$s', 'ai-search' ), $status_code, $error_message ); set_transient( 'ai_search_quota_api_error', $error, 60 ); error_log( 'AI Search: Quota API returned status ' . $status_code . ' - ' . $error_message ); return false; } // Validate and normalize the response if ( ! is_array( $body ) ) { $error = __( 'Invalid response format from API', 'ai-search' ); set_transient( 'ai_search_quota_api_error', $error, 60 ); error_log( 'AI Search: ' . $error ); return false; } // Transform API response to expected format // API Response structure: // { // "limits": { "daily": 1000, "total": 100000 }, // "usage": { "daily": 1, "total": 1001 }, // "remaining": { "daily": 999, "total": 98999 }, // "last_reset": "2026-01-20T17:05:03.000Z", // "quotas_exceeded": { "daily": false, "total": false } // } return [ 'total_embeddings_used' => isset( $body['usage']['total'] ) ? intval( $body['usage']['total'] ) : 0, 'total_embeddings_limit' => isset( $body['limits']['total'] ) ? intval( $body['limits']['total'] ) : 10000, 'daily_usage' => isset( $body['usage']['daily'] ) ? intval( $body['usage']['daily'] ) : 0, 'daily_limit' => isset( $body['limits']['daily'] ) ? intval( $body['limits']['daily'] ) : 1000, 'daily_quota_exceeded' => isset( $body['quotas_exceeded']['daily'] ) ? (bool) $body['quotas_exceeded']['daily'] : false, 'total_quota_exceeded' => isset( $body['quotas_exceeded']['total'] ) ? (bool) $body['quotas_exceeded']['total'] : false, // Plan and subscription info returned since service v1.33 // (billing launch). Older service responses omit these keys; // callers must tolerate them being null. 'plan' => isset( $body['plan'] ) && is_array( $body['plan'] ) ? $body['plan'] : null, 'subscription' => isset( $body['subscription'] ) && is_array( $body['subscription'] ) ? $body['subscription'] : null, ]; } /** * Attach this site's origin to a paid subscription using the claim key * the customer received by email after checkout. * * @since 1.34.0 * * @param string $claim_key UUID handed to the customer by the service. * @return array|WP_Error The service's success payload * `[plan => [...], subscription => [...]]`, or WP_Error on any 4xx/5xx. * * Error codes returned by this call: * `no_token` - This site has never registered with the service. * `invalid_claim_key` - Wrong shape (not a UUID). * `claim_key_unknown` - Service does not recognize the key (404). * `claim_key_used` - Already redeemed. * `claim_key_expired` - Past its expiry. * `no_slots_available` - Subscription has no free slot. * `already_attached` - This site is already on a paid subscription. * `subscription_not_active` - Subscription is canceled/incomplete. * `http_error` - Network / transport failure. * `service_error` - Anything else non-2xx. */ public function claim_subscription( $claim_key ) { $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { return new WP_Error( 'no_token', __( 'This site is not registered with the AI Search service. Complete setup first.', 'ai-search' ) ); } $claim_key = trim( (string) $claim_key ); if ( ! preg_match( '/^[0-9a-f\-]{36}$/i', $claim_key ) ) { return new WP_Error( 'invalid_claim_key', __( 'That does not look like a valid activation key. Copy it from your welcome email exactly as shown.', 'ai-search' ) ); } $response = wp_remote_post( $this->service_url . 'clients/claim-subscription', [ 'headers' => [ 'Authorization' => 'Bearer ' . $service_token, 'Origin' => get_site_url(), 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'claim_key' => $claim_key ] ), 'timeout' => 20, ] ); if ( is_wp_error( $response ) ) { return new WP_Error( 'http_error', sprintf( /* translators: %s: transport error message */ __( 'Could not reach the AI Search service: %s', 'ai-search' ), $response->get_error_message() ) ); } $status = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status === 200 && is_array( $body ) && isset( $body['plan'] ) ) { return $body; } // Map the specific 4xx errors the service returns to codes the UI // can render with tailored messages. $error_text = is_array( $body ) && isset( $body['error'] ) ? (string) $body['error'] : ''; $code = 'service_error'; if ( $status === 404 ) { $code = 'claim_key_unknown'; } elseif ( $status === 409 ) { if ( stripos( $error_text, 'used' ) !== false ) $code = 'claim_key_used'; elseif ( stripos( $error_text, 'expired' ) !== false ) $code = 'claim_key_expired'; elseif ( stripos( $error_text, 'no slots' ) !== false ) $code = 'no_slots_available'; elseif ( stripos( $error_text, 'already attached' ) !== false ) $code = 'already_attached'; else $code = 'subscription_not_active'; } return new WP_Error( $code, $error_text !== '' ? $error_text : sprintf( /* translators: %d: HTTP status */ __( 'Activation failed (HTTP %d).', 'ai-search' ), (int) $status ), [ 'status' => $status ] ); } /** * Detach this site from its paid subscription so the slot can be * reused on another site. * * @since 1.34.0 * * @return true|WP_Error True on success, WP_Error otherwise. */ public function release_slot() { $service_token = get_option( 'ai_search_service_token' ); if ( empty( $service_token ) ) { return new WP_Error( 'no_token', __( 'This site is not registered with the AI Search service.', 'ai-search' ) ); } $response = wp_remote_post( $this->service_url . 'clients/release-slot', [ 'headers' => [ 'Authorization' => 'Bearer ' . $service_token, 'Origin' => get_site_url(), 'Content-Type' => 'application/json', ], 'timeout' => 20, ] ); if ( is_wp_error( $response ) ) { return new WP_Error( 'http_error', $response->get_error_message() ); } $status = wp_remote_retrieve_response_code( $response ); if ( $status === 200 ) { return true; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status === 404 ) { return new WP_Error( 'not_attached', __( 'This site was not attached to any subscription.', 'ai-search' ) ); } return new WP_Error( 'service_error', is_array( $body ) && isset( $body['error'] ) ? $body['error'] : __( 'Could not release the site.', 'ai-search' ) ); } }