diff --git a/.distignore b/.distignore index f0b0e6a..923ad27 100644 --- a/.distignore +++ b/.distignore @@ -30,6 +30,7 @@ tailwind.config.js /.wordpress-org /.idea /src +/patches /bin /tests /phpunit.xml diff --git a/composer.json b/composer.json index d73c334..83d0c50 100644 --- a/composer.json +++ b/composer.json @@ -43,11 +43,20 @@ }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, - "php-http/discovery": true + "php-http/discovery": true, + "cweagans/composer-patches": true } }, + "extra": { + "patches": { + "your1/qdrant": { + "Add #[\\ReturnTypeWillChange] to Response::offsetGet() for PHP 8.1+ compatibility": "patches/qdrant-response-returntypewillchange.patch" + } + } + }, "require": { "codeinwp/themeisle-sdk": "^3.3", + "cweagans/composer-patches": "^1.7", "guttedgarden/tiktoken": "^1.2", "your1/qdrant": "^0.5.3" } diff --git a/composer.lock b/composer.lock index 47a2c41..1dacfd1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f67fd9fc28c5d7b4d8a61dac180bc49b", + "content-hash": "da3e0a9003acbadb89175339f2c6215d", "packages": [ { "name": "codeinwp/themeisle-sdk", @@ -47,6 +47,54 @@ }, "time": "2026-06-23T13:43:47+00:00" }, + { + "name": "cweagans/composer-patches", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-patches.git", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3.0" + }, + "require-dev": { + "composer/composer": "~1.0 || ~2.0", + "phpunit/phpunit": "~4.6" + }, + "type": "composer-plugin", + "extra": { + "class": "cweagans\\Composer\\Patches" + }, + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a way to patch Composer packages.", + "support": { + "issues": "https://github.com/cweagans/composer-patches/issues", + "source": "https://github.com/cweagans/composer-patches/tree/1.7.3" + }, + "time": "2022-12-20T22:53:13+00:00" + }, { "name": "guttedgarden/tiktoken", "version": "1.2.1", diff --git a/inc/API.php b/inc/API.php index b5af7b5..756b76b 100644 --- a/inc/API.php +++ b/inc/API.php @@ -292,7 +292,7 @@ public function update_settings( $request ) { } if ( empty( $updated ) ) { - return rest_ensure_response( [ 'error' => __( 'No settings to update.', 'hyve-lite' ) ] ); + return $this->settings_response( [ 'success' => __( 'Settings are already up to date.', 'hyve-lite' ) ] ); } $validation = apply_filters( @@ -418,7 +418,7 @@ function ( $carry, $item ) { foreach ( $updated as $key => $value ) { if ( ! $validation[ $key ]['validate']( $value ) ) { - return rest_ensure_response( + return $this->settings_response( [ // translators: %s: option key. 'error' => sprintf( __( 'Invalid value: %s', 'hyve-lite' ), $key ), @@ -429,15 +429,34 @@ function ( $carry, $item ) { $updated[ $key ] = $validation[ $key ]['sanitize']( $value ); } + $api_warning = ''; + $api_key_error = null; + $key_validated = false; + foreach ( $updated as $key => $value ) { $settings[ $key ] = $value; if ( 'api_key' === $key && ! empty( $value ) ) { - $openai = new OpenAI( $value ); - $valid_api = $openai->moderate( 'This is a test message.' ); + $openai = new OpenAI( $value ); + + // Validate against the embeddings endpoint. Suppress automatic + // persistence: the dashboard notice is reconciled after the save + // actually lands, so it can never reflect a key that was not + // stored. See Codeinwp/hyve#149. + $validation = $openai->set_error_persistence( false )->create_embeddings( 'Test connection.' ); + $key_validated = true; + + if ( is_wp_error( $validation ) && $this->is_auth_error( $validation->get_error_code() ) ) { + // The key itself is invalid — block the save. + return $this->settings_response( [ 'error' => $this->get_error_message( $validation ) ] ); + } - if ( is_wp_error( $valid_api ) ) { - return rest_ensure_response( [ 'error' => $this->get_error_message( $valid_api ) ] ); + if ( is_wp_error( $validation ) ) { + // The key is well-formed but the account is rate-limited or + // has no credits (new, unfunded accounts return a 429). Save + // the key but warn; the notice is recorded after the save. + $api_warning = $this->get_error_message( $validation ); + $api_key_error = $validation; } } @@ -451,13 +470,63 @@ function ( $carry, $item ) { $init = $qdrant->init(); if ( is_wp_error( $init ) ) { - return rest_ensure_response( [ 'error' => $this->get_error_message( $init ) ] ); + return $this->settings_response( [ 'error' => $this->get_error_message( $init ) ] ); } } update_option( 'hyve_settings', $settings ); - return rest_ensure_response( __( 'Settings updated.', 'hyve-lite' ) ); + // Reconcile the dashboard service-error notice with the key that was just + // saved — only now that the save has actually landed (no earlier exit can + // leave a notice for an unsaved key). Clear any stale notice first, then + // re-record one for the new key when its error is actionable (a no-op for + // transient codes such as rate limits). See Codeinwp/hyve#149. + if ( $key_validated ) { + delete_option( OpenAI::ERROR_OPTION_KEY ); + + if ( null !== $api_key_error ) { + OpenAI::instance()->save_service_error( $api_key_error ); + } + } + + if ( ! empty( $api_warning ) ) { + return $this->settings_response( [ 'warning' => $api_warning ] ); + } + + return $this->settings_response( [ 'success' => __( 'Settings updated.', 'hyve-lite' ) ] ); + } + + /** + * Build a settings REST response carrying the current service errors. + * + * Returning the freshly-computed service errors lets the dashboard notice + * update immediately after a save, without a page reload. See Codeinwp/hyve#200. + * + * @param array $payload The response payload. + * + * @return \WP_REST_Response + */ + private function settings_response( $payload ) { + $options = apply_filters( 'hyve_options_data', [] ); + + $payload['serviceErrors'] = ( is_array( $options ) && isset( $options['serviceErrors'] ) ) ? $options['serviceErrors'] : []; + + return rest_ensure_response( $payload ); + } + + /** + * Whether an error code means the API key itself is invalid. + * + * These block a key from being saved. Account-level problems (no credits, + * billing, rate limits) do not: the key is valid, the account just needs + * attention, so we save it and warn instead of blocking. See Codeinwp/hyve#149. + * + * @param int|string $code The error code. + * + * @return bool + */ + private function is_auth_error( $code ) { + return in_array( $code, OpenAI::AUTH_ERROR_CODES, true ); } /** @@ -584,6 +653,12 @@ public function get_data( $request ) { $post_data['review'] = $review; } + $processing_error = get_post_meta( $post_id, '_hyve_processing_error', true ); + + if ( ! empty( $processing_error ) && get_post_meta( $post_id, '_hyve_added', true ) ) { + $post_data['error'] = $processing_error; + } + $posts_data[] = $post_data; } } @@ -628,6 +703,20 @@ public function add_data( $request ) { return rest_ensure_response( [ 'error' => $this->get_error_message( $process ) ] ); } + // The content was stored, but the synchronous indexing attempt may have + // failed (e.g. rate limit, no credits). Surface that as a non-blocking + // warning so the admin sees it immediately, not only as a list badge. + $processing_error = get_post_meta( $post_id, '_hyve_processing_error', true ); + + if ( ! empty( $processing_error ) ) { + return rest_ensure_response( + [ + 'success' => true, + 'warning' => $processing_error, + ] + ); + } + return rest_ensure_response( true ); } @@ -646,8 +735,8 @@ public function delete_data( $request ) { try { $delete_result = Qdrant_API::instance()->delete_point( $id ); - if ( ! $delete_result ) { - throw new \Exception( __( 'Failed to delete point in Qdrant.', 'hyve-lite' ) ); + if ( is_wp_error( $delete_result ) || ! $delete_result ) { + throw new \Exception( is_wp_error( $delete_result ) ? $delete_result->get_error_message() : __( 'Failed to delete point in Qdrant.', 'hyve-lite' ) ); } } catch ( \Exception $e ) { return rest_ensure_response( [ 'error' => $e->getMessage() ] ); @@ -660,6 +749,7 @@ public function delete_data( $request ) { delete_post_meta( $id, '_hyve_needs_update' ); delete_post_meta( $id, '_hyve_moderation_failed' ); delete_post_meta( $id, '_hyve_moderation_review' ); + delete_post_meta( $id, '_hyve_processing_error' ); return rest_ensure_response( true ); } diff --git a/inc/BaseAPI.php b/inc/BaseAPI.php index 9265752..077f7cc 100644 --- a/inc/BaseAPI.php +++ b/inc/BaseAPI.php @@ -51,12 +51,10 @@ public function __construct() { * @return string */ public function get_error_message( $error ) { - $errors = [ - 'invalid_api_key' => __( 'Incorrect API key provided.', 'hyve-lite' ), - 'missing_scope' => __( 'You have insufficient permissions for this operation.', 'hyve-lite' ), - ]; - if ( isset( $errors[ $error->get_error_code() ] ) ) { - return $errors[ $error->get_error_code() ]; + $message = OpenAI::get_error_message_for_code( $error->get_error_code() ); + + if ( null !== $message ) { + return $message; } return $error->get_error_message(); diff --git a/inc/DB_Table.php b/inc/DB_Table.php index 584e200..27a577e 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -40,6 +40,14 @@ class DB_Table { */ const CACHE_PREFIX = 'hyve-'; + /** + * Maximum number of times a chunk is retried after a transient failure + * before it is marked failed. See Codeinwp/hyve#199. + * + * @var int + */ + const MAX_PROCESS_ATTEMPTS = 5; + /** * The single instance of the class. * @@ -69,7 +77,15 @@ public function __construct() { global $wpdb; $this->table_name = $wpdb->prefix . 'hyve'; - add_action( 'hyve_process_post', [ $this, 'process_post' ], 10, 1 ); + add_action( + 'hyve_process_post', + function ( $id ) { + // Discard the return value: a cron/action callback must not return anything. + $this->process_post( $id ); + }, + 10, + 1 + ); add_action( 'hyve_delete_posts', [ $this, 'delete_posts' ], 10, 1 ); add_action( 'hyve_update_posts', [ $this, 'update_posts' ] ); @@ -465,27 +481,96 @@ public function get_posts_over_limit() { * @throws \Exception If Qdrant API fails. */ public function add_post( $post_id, $action = 'add' ) { - $data = [ - 'ID' => $post_id, - 'title' => html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ), - 'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ), - ]; - update_post_meta( $post_id, '_hyve_post_processing', 1 ); - $data = Tokenizer::tokenize( $data ); - $chunks = array_column( $data, 'post_content' ); - $moderation = OpenAI::instance()->moderate_chunks( $chunks, $post_id ); + $result = $this->ingest_document( + [ + 'title' => get_the_title( $post_id ), + 'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ), + ], + [ + 'action' => 'update' === $action ? 'update' : 'add', + 'override' => 'override' === $action, + 'post_id' => $post_id, + 'create' => false, + 'persist_moderation' => true, + 'retry_async' => 'update' === $action, + ] + ); + + delete_post_meta( $post_id, '_hyve_post_processing' ); + + return $result; + } + + /** + * Ingest a document into the knowledge base. + * + * Shared pipeline for every data source (posts, custom data, links and + * sitemap entries): tokenize -> moderate -> (replace old chunks on update) + * -> insert chunk rows -> embed. The only things that vary between sources + * are how the owning post is resolved and what extra meta it carries, both + * expressed through `$args`. + * + * @since 1.4.0 + * + * @param array $doc The document, with `title` and `content` keys. + * @param array $args { + * Optional. Ingestion options. + * + * @type string $action 'add' or 'update'. Default 'add'. + * @type bool $override Skip the moderation gate. Default false. + * @type int|null $post_id Existing post to attach chunks to. Required + * for the posts source and for any update. + * @type bool $create Create (add) or update a managed `hyve_docs` + * post. False for the posts source, where the + * WP post already exists and is left untouched. + * Default false. + * @type string $post_type Post type to create when `create` is true. + * Default 'hyve_docs'. + * @type array $meta Extra post meta to set on success. + * @type bool $persist_moderation Store/clear `_hyve_moderation_*` meta on + * `post_id`. Default false. + * @type bool $retry_async Retry a failed embedding in the background + * via cron. When false, failures are terminal + * and recorded for immediate, synchronous + * surfacing to the caller. Default true. + * } + * + * @return true|\WP_Error + * @throws \Exception If Qdrant API fails. + */ + public function ingest_document( $doc, $args = [] ) { + $action = $args['action'] ?? 'add'; + $override = ! empty( $args['override'] ); + $create = ! empty( $args['create'] ); + $post_id = $args['post_id'] ?? null; + $post_type = $args['post_type'] ?? 'hyve_docs'; + $extra_meta = $args['meta'] ?? []; + $persist_moderation = ! empty( $args['persist_moderation'] ); + $allow_retry = $args['retry_async'] ?? true; + + $data = Tokenizer::tokenize( + [ + 'ID' => $post_id, + 'title' => $doc['title'], + 'content' => $doc['content'], + ] + ); + $chunks = array_column( $data, 'post_content' ); + // Only reuse the per-post moderation cache for an existing WP post + // (the posts source). Freshly submitted content is always re-moderated. + $moderation = OpenAI::instance()->moderate_chunks( $chunks, $persist_moderation ? $post_id : null ); if ( is_wp_error( $moderation ) ) { - delete_post_meta( $post_id, '_hyve_post_processing' ); return $moderation; } - if ( true !== $moderation && 'override' !== $action ) { - delete_post_meta( $post_id, '_hyve_post_processing' ); - update_post_meta( $post_id, '_hyve_moderation_failed', 1 ); - update_post_meta( $post_id, '_hyve_moderation_review', $moderation ); + if ( true !== $moderation && ! $override ) { + if ( $persist_moderation && $post_id ) { + update_post_meta( $post_id, '_hyve_moderation_failed', 1 ); + update_post_meta( $post_id, '_hyve_moderation_review', $moderation ); + } return new \WP_Error( 'content_failed_moderation', @@ -494,34 +579,83 @@ public function add_post( $post_id, $action = 'add' ) { ); } + // Resolve the owning post. Moderation has passed, so creating/updating a + // managed post here cannot leave an orphan behind on rejection. if ( 'update' === $action ) { if ( Qdrant_API::is_active() ) { try { $delete_result = Qdrant_API::instance()->delete_point( $post_id ); - if ( ! $delete_result ) { - throw new \Exception( __( 'Failed to delete point in Qdrant.', 'hyve-lite' ) ); + if ( is_wp_error( $delete_result ) || ! $delete_result ) { + throw new \Exception( is_wp_error( $delete_result ) ? $delete_result->get_error_message() : __( 'Failed to delete point in Qdrant.', 'hyve-lite' ) ); } } catch ( \Exception $e ) { - delete_post_meta( $post_id, '_hyve_post_processing' ); return new \WP_Error( 'qdrant_error', $e->getMessage() ); } } $this->delete_by_post_id( $post_id ); + + if ( $create ) { + $updated = wp_update_post( + [ + 'ID' => $post_id, + 'post_title' => $doc['title'], + 'post_content' => $doc['content'], + ] + ); + + if ( ! $updated ) { + return new \WP_Error( 'failed_update_post', __( 'Failed to update post.', 'hyve-lite' ) ); + } + } + } elseif ( $create ) { + $post_id = wp_insert_post( + [ + 'post_title' => $doc['title'], + 'post_content' => $doc['content'], + 'post_status' => 'publish', + 'post_type' => $post_type, + ] + ); + + if ( ! $post_id ) { + return new \WP_Error( 'failed_insert_post', __( 'Failed to insert post.', 'hyve-lite' ) ); + } } + $processing_error = null; + foreach ( $data as $datum ) { - $id = $this->insert( $datum ); - $this->process_post( $id ); + $datum['post_id'] = $post_id; + + $id = $this->insert( $datum ); + $result = $this->process_post( $id, $allow_retry ); + + if ( is_wp_error( $result ) && null === $processing_error ) { + $processing_error = $result; + } } - delete_post_meta( $post_id, '_hyve_post_processing' ); update_post_meta( $post_id, '_hyve_added', 1 ); - delete_post_meta( $post_id, '_hyve_moderation_failed' ); - delete_post_meta( $post_id, '_hyve_moderation_review' ); - delete_post_meta( $post_id, '_hyve_needs_update' ); - $this->delete_cache( 'cached_embeddings' ); + + foreach ( $extra_meta as $meta_key => $meta_value ) { + update_post_meta( $post_id, $meta_key, $meta_value ); + } + + // When retries are disabled a chunk failure is terminal, so record the + // reason against the post. This also restores the error if a later + // chunk's success cleared it mid-loop, keeping the surfaced reason honest. + if ( ! $allow_retry && null !== $processing_error ) { + $this->record_processing_error( $post_id, $processing_error, false ); + } + + if ( $persist_moderation ) { + delete_post_meta( $post_id, '_hyve_moderation_failed' ); + delete_post_meta( $post_id, '_hyve_moderation_review' ); + delete_post_meta( $post_id, '_hyve_needs_update' ); + $this->delete_cache( 'cached_embeddings' ); + } return true; } @@ -531,11 +665,16 @@ public function add_post( $post_id, $action = 'add' ) { * * @since 1.2.0 * - * @param int $id The post ID. - * - * @return void + * @param int $id The post ID. + * @param bool $allow_retry Whether a transient failure may be retried + * asynchronously via the hyve_process_post cron. + * When false, a failure is terminal and returned to + * the caller for immediate, synchronous handling. + * Default true. + * + * @return true|\WP_Error True on success, or the error encountered. */ - public function process_post( $id ) { + public function process_post( $id, $allow_retry = true ) { $post = $this->get( $id ); $content = $post->post_content; $openai = OpenAI::instance(); @@ -543,8 +682,12 @@ public function process_post( $id ) { $embeddings = $openai->create_embeddings( $stripped ); if ( is_wp_error( $embeddings ) || ! $embeddings ) { - wp_schedule_single_event( time() + 60, 'hyve_process_post', [ $id ] ); - return; + $error = is_wp_error( $embeddings ) + ? $embeddings + : new \WP_Error( 'unknown_error', __( 'An unexpected error occurred while indexing this content.', 'hyve-lite' ) ); + + $this->handle_processing_failure( $id, (int) $post->post_id, $error, $allow_retry ); + return $error; } $embeddings = reset( $embeddings ); @@ -570,8 +713,8 @@ public function process_post( $id ) { } if ( is_wp_error( $success ) ) { - wp_schedule_single_event( time() + 60, 'hyve_process_post', [ $id ] ); - return; + $this->handle_processing_failure( $id, (int) $post->post_id, $success, $allow_retry ); + return $success; } } @@ -583,8 +726,84 @@ public function process_post( $id ) { 'embeddings' => $embeddings, 'post_status' => 'processed', 'storage' => $storage, - ] + ] ); + + // Processing succeeded; clear any error and retry counter from a previous attempt. + delete_post_meta( (int) $post->post_id, '_hyve_processing_error' ); + delete_transient( self::CACHE_PREFIX . 'process_attempts_' . $id ); + + return true; + } + + /** + * Handle a failed processing attempt. + * + * Fatal errors (bad key, no credits, billing) will not resolve by retrying, + * so they stop immediately and the entry is marked failed. Transient errors + * (rate limits, network blips) are retried with a linear backoff up to + * MAX_PROCESS_ATTEMPTS, then also marked failed. Either way the reason is + * recorded for the Knowledge Base UI. See Codeinwp/hyve#199. + * + * @since 1.4.0 + * + * @param int $id The chunk row ID. + * @param int $post_id The source post ID. + * @param \WP_Error $error The error encountered while processing. + * @param bool $allow_retry Whether an async retry may be scheduled. When + * false the failure is terminal. Default true. + * + * @return void + */ + private function handle_processing_failure( $id, $post_id, $error, $allow_retry = true ) { + $transient = self::CACHE_PREFIX . 'process_attempts_' . $id; + $attempts = (int) get_transient( $transient ) + 1; + $is_fatal = OpenAI::is_fatal_error_code( $error->get_error_code() ); + $will_retry = $allow_retry && ! $is_fatal && $attempts < self::MAX_PROCESS_ATTEMPTS; + + $this->record_processing_error( $post_id, $error, $will_retry ); + + if ( $will_retry ) { + set_transient( $transient, $attempts, DAY_IN_SECONDS ); + wp_schedule_single_event( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] ); + return; + } + + // Terminal: stop retrying and mark the chunk failed. + delete_transient( $transient ); + $this->update( $id, [ 'post_status' => 'failed' ] ); + } + + /** + * Record a processing error against the source post so it can be surfaced + * in the Knowledge Base UI instead of failing silently. The stored message + * includes whether the entry will be retried or needs the admin to act. + * See Codeinwp/hyve#199. + * + * @since 1.4.0 + * + * @param int $post_id The source post ID. + * @param \WP_Error $error The error encountered while processing. + * @param bool $will_retry Whether another attempt is scheduled. + * + * @return void + */ + private function record_processing_error( $post_id, $error, $will_retry ) { + if ( empty( $post_id ) ) { + return; + } + + $message = OpenAI::get_error_message_for_code( $error->get_error_code() ); + + if ( null === $message ) { + $message = $error->get_error_message(); + } + + $suffix = $will_retry + ? __( 'It will be retried automatically.', 'hyve-lite' ) + : __( 'Please fix the problem and re-add this content.', 'hyve-lite' ); + + update_post_meta( $post_id, '_hyve_processing_error', $message . ' ' . $suffix ); } /** diff --git a/inc/Main.php b/inc/Main.php index 4371e70..5b825cc 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -811,13 +811,27 @@ public function append_services_error( $options ) { $errors = []; $open_ai_last_error = get_option( OpenAI::ERROR_OPTION_KEY, false ); - if ( is_array( $open_ai_last_error ) ) { + if ( is_array( $open_ai_last_error ) && $this->is_recent_error( $open_ai_last_error ) ) { + if ( ! empty( $open_ai_last_error['code'] ) ) { + $friendly_message = OpenAI::get_error_message_for_code( $open_ai_last_error['code'] ); + + if ( null !== $friendly_message ) { + $open_ai_last_error['message'] = $friendly_message; + } + } + $errors[] = $open_ai_last_error; } $qdrant_last_error = get_option( Qdrant_API::ERROR_OPTION_KEY, false ); - if ( is_array( $qdrant_last_error ) ) { - $qdrant_last_error['message'] = __( 'Invalid credentials.', 'hyve-lite' ) . ' ' . __( 'Please check your API key and endpoint URL.', 'hyve-lite' ); + if ( is_array( $qdrant_last_error ) && $this->is_recent_error( $qdrant_last_error ) ) { + $friendly_message = ! empty( $qdrant_last_error['code'] ) ? Qdrant_API::get_error_message_for_code( $qdrant_last_error['code'] ) : null; + + if ( null === $friendly_message ) { + $friendly_message = __( 'Hyve could not connect to Qdrant.', 'hyve-lite' ) . ' ' . __( 'Please check your API key and endpoint URL in the Integrations settings.', 'hyve-lite' ); + } + + $qdrant_last_error['message'] = $friendly_message; $errors[] = $qdrant_last_error; } @@ -828,6 +842,31 @@ public function append_services_error( $options ) { return $options; } + /** + * Whether a saved service error is recent enough to surface to the admin. + * + * A successful request already clears the saved error, so this only guards + * against a stale failure lingering on a site with no traffic since: we only + * show errors from the last 24 hours that still have no subsequent success. + * + * @param array $error The saved error. + * + * @return bool + */ + private function is_recent_error( $error ) { + if ( empty( $error['date'] ) ) { + return false; + } + + $timestamp = strtotime( $error['date'] ); + + if ( false === $timestamp ) { + return false; + } + + return $timestamp >= ( time() - DAY_IN_SECONDS ); + } + /** * Get the data for Formbricks survey. * diff --git a/inc/OpenAI.php b/inc/OpenAI.php index ae712ba..5a7df4e 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -36,11 +36,21 @@ class OpenAI { /** * API Key. - * + * * @var string */ private $api_key; + /** + * Whether request errors should be persisted as a service error notice. + * + * Disabled while validating a candidate key that may be rejected, so a key + * that never gets saved does not leave a dashboard notice behind. + * + * @var bool + */ + private $persist_errors = true; + /** * The single instance of the class. * @@ -50,11 +60,43 @@ class OpenAI { /** * The service error option key for `wp_options`. - * + * * @var string */ public const ERROR_OPTION_KEY = 'hyve_open_ai_api_error'; + /** + * Error codes that mean the API key itself is invalid (block a key save). + * + * @var string[] + */ + public const AUTH_ERROR_CODES = [ + 'invalid_api_key', + 'invalid_authentication', + 'missing_scope', + 'permission_denied', + ]; + + /** + * Error codes worth persisting as a dashboard notice — those that require the + * site admin to act (key, auth, billing, quota, account). Transient errors + * such as rate_limit_exceeded are intentionally excluded: they self-resolve, + * and saving them would clobber a real actionable error in this single slot. + * + * @var string[] + */ + public const PERSISTED_ERROR_CODES = [ + 'invalid_api_key', + 'invalid_authentication', + 'insufficient_quota', + 'quota_exceeded', + 'billing_not_active', + 'account_deactivated', + 'organization_not_found', + 'organization_deactivated', + 'permission_denied', + ]; + /** * Ensures only one instance of the class is loaded. * @@ -79,6 +121,57 @@ public function __construct( $api_key = '' ) { $this->chat_model = isset( $settings['chat_model'] ) ? $settings['chat_model'] : $this->chat_model; } + /** + * Set whether request errors should be persisted as a service error notice. + * + * @param bool $persist Whether to persist errors. + * + * @return OpenAI + */ + public function set_error_persistence( $persist ) { + $this->persist_errors = (bool) $persist; + return $this; + } + + /** + * Whether an error code is fatal — it requires the site admin to act (key, + * auth, billing, quota, account) and will not resolve by retrying. Anything + * else (rate limits, network blips, transient 5xx) is treated as retryable. + * + * @param int|string $code The error code. + * + * @return bool + */ + public static function is_fatal_error_code( $code ) { + return in_array( $code, self::AUTH_ERROR_CODES, true ) + || in_array( $code, self::PERSISTED_ERROR_CODES, true ); + } + + /** + * Persist a service error so it surfaces on the dashboard. + * + * Subject to the same actionable-code allow list as runtime errors (so, for + * example, rate limits are not persisted). Used when a key is saved despite + * an account-level problem, to explicitly record what a real request would. + * + * @param \WP_Error $error The error to persist. + * + * @return void + */ + public function save_service_error( $error ) { + $previous = $this->persist_errors; + $this->persist_errors = true; + + $this->check_and_save_error( + [ + 'code' => (string) $error->get_error_code(), + 'message' => $error->get_error_message(), + ] + ); + + $this->persist_errors = $previous; + } + /** * Create Embeddings. * @@ -388,17 +481,35 @@ private function request( $endpoint, $params = [], $method = 'POST' ) { $body = json_decode( $body ); if ( isset( $body->error ) ) { - if ( 'POST' === $method ) { - $this->check_and_save_error( (array) $body->error ); + $error_code = ! empty( $body->error->code ) ? $body->error->code : ''; + + // OpenAI can return a 429 with a null code (e.g. brand-new + // accounts with no credits, even on the free moderation + // endpoint). Fall back to the HTTP status so the error stays + // mappable to an actionable message. + if ( '' === $error_code && 429 === (int) wp_remote_retrieve_response_code( $response ) ) { + $error_code = 'rate_limit_exceeded'; + } + + if ( '' === $error_code ) { + $error_code = 'unknown_error'; } - if ( isset( $body->error->message ) ) { - return new \WP_Error( isset( $body->error->code ) ? $body->error->code : 'unknown_error', $body->error->message ); + $error_message = isset( $body->error->message ) ? $body->error->message : __( 'An error occurred while processing the request.', 'hyve-lite' ); + + if ( 'POST' === $method ) { + $this->check_and_save_error( + [ + 'code' => $error_code, + 'message' => $error_message, + ] + ); } - return new \WP_Error( 'unknown_error', __( 'An error occurred while processing the request.', 'hyve-lite' ) ); + + return new \WP_Error( $error_code, $error_message ); } - if ( 'POST' === $method ) { + if ( 'POST' === $method && $this->persist_errors ) { delete_option( self::ERROR_OPTION_KEY ); } return $body; @@ -414,30 +525,17 @@ private function request( $endpoint, $params = [], $method = 'POST' ) { * @return void */ private function check_and_save_error( $error ) { + if ( ! $this->persist_errors ) { + return; + } + if ( empty( $error['code'] ) ) { - return; } $code = $error['code']; - $errors_codes = [ - // API Key Errors. - 'invalid_api_key', - 'insufficient_quota', - 'invalid_authentication', - 'account_deactivated', - 'billing_not_active', - 'organization_not_found', - 'organization_deactivated', - 'permission_denied', - - // Rate Limiting Errors. - 'rate_limit_exceeded', - 'quota_exceeded ', - ]; - - if ( in_array( $code, $errors_codes, true ) ) { + if ( in_array( $code, self::PERSISTED_ERROR_CODES, true ) ) { update_option( self::ERROR_OPTION_KEY, [ @@ -445,8 +543,43 @@ private function check_and_save_error( $error ) { 'message' => ! empty( $error['message'] ) ? $error['message'] : '', 'date' => wp_date( 'c' ), 'provider' => 'OpenAI', - ] + ] ); } } + + /** + * Translate an OpenAI error code into an actionable, user-facing message. + * + * This is the single source of truth for OpenAI error copy. Callers that + * surface an error to the user (REST responses, the dashboard notice) should + * run the code through here and fall back to the raw provider message when + * this returns null. + * + * @param int|string $code The OpenAI error code. + * + * @return string|null The actionable message, or null when the code is unmapped. + */ + public static function get_error_message_for_code( $code ) { + $quota_message = __( 'Your OpenAI account has no available credits. If you are using a free API key, please add billing or upgrade to a paid plan to use AI features.', 'hyve-lite' ); + $auth_message = __( 'OpenAI could not authenticate the request. Please verify your API key in Settings → Advanced.', 'hyve-lite' ); + $scope_message = __( 'Your OpenAI API key lacks permission for this operation. Please use a key with the required scopes.', 'hyve-lite' ); + $org_message = __( 'Your OpenAI organization could not be found or is no longer active. Please check your OpenAI account settings.', 'hyve-lite' ); + + $messages = [ + 'invalid_api_key' => __( 'The OpenAI API key is incorrect. Please double-check it in Settings → Advanced.', 'hyve-lite' ), + 'invalid_authentication' => $auth_message, + 'missing_scope' => $scope_message, + 'permission_denied' => $scope_message, + 'insufficient_quota' => $quota_message, + 'quota_exceeded' => $quota_message, + 'billing_not_active' => __( 'Billing is not active on your OpenAI account. Please add a payment method in your OpenAI billing settings.', 'hyve-lite' ), + 'account_deactivated' => __( 'Your OpenAI account has been deactivated. Please contact OpenAI support to restore access.', 'hyve-lite' ), + 'organization_not_found' => $org_message, + 'organization_deactivated' => $org_message, + 'rate_limit_exceeded' => __( 'OpenAI returned a rate limit response (HTTP 429). If you recently created this account or key, it may not have any credits yet — add a payment method or credits in your OpenAI billing settings. Otherwise you may be sending requests too quickly; wait a moment and try again.', 'hyve-lite' ), + ]; + + return $messages[ $code ] ?? null; + } } diff --git a/inc/Qdrant_API.php b/inc/Qdrant_API.php index 9d56dd2..913260c 100644 --- a/inc/Qdrant_API.php +++ b/inc/Qdrant_API.php @@ -17,6 +17,7 @@ use Qdrant\Models\Filter\Filter; use Qdrant\Models\Filter\Condition\MatchString; use Qdrant\Models\Request\CreateCollection; +use Qdrant\Models\Request\CreateIndex; use Qdrant\Models\Request\SearchRequest; use Qdrant\Models\Request\VectorParams; @@ -109,6 +110,14 @@ public function init() { } } + // Ensure the payload index required to filter/delete by post_id exists, + // including on collections created before this index was introduced. + $payload_index = $this->ensure_payload_index(); + + if ( is_wp_error( $payload_index ) ) { + return $payload_index; + } + update_option( 'hyve_qdrant_status', 'active' ); $existing_chunks = DB_Table::instance()->get_count(); @@ -152,19 +161,7 @@ public function collection_exists() { return false; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - update_option( - self::ERROR_OPTION_KEY, - [ - 'code' => $e->getCode(), - 'date' => wp_date( 'c' ), - 'provider' => 'Qdrant', - ] - ); - } - - return new \WP_Error( 'collection_error', $e->getMessage() ); + return $this->handle_exception( $e ); } } @@ -182,19 +179,32 @@ public function create_collection() { return $response['result']; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - update_option( - self::ERROR_OPTION_KEY, - [ - 'code' => $e->getCode(), - 'date' => wp_date( 'c' ), - 'provider' => 'Qdrant', - ] - ); - } + return $this->handle_exception( $e ); + } + } - return new \WP_Error( 'collection_error', $e->getMessage() ); + /** + * Ensure the payload index needed to filter and delete by post_id exists. + * + * Qdrant requires a keyword index on a payload field before it can be used in + * a filter. Without it, deleting or updating points by post_id fails with a + * 400 Bad Request and the vectors are left orphaned. Creating an index that + * already exists is a no-op, so this is safe to run on every init. + * + * @since 1.4.2 + * + * @return bool|\WP_Error + */ + public function ensure_payload_index() { + try { + $this->client->collections( self::COLLECTION_NAME )->index()->create( + new CreateIndex( 'post_id', 'keyword' ) + ); + delete_option( self::ERROR_OPTION_KEY ); + + return true; + } catch ( \Exception $e ) { + return $this->handle_exception( $e ); } } @@ -222,11 +232,7 @@ public function add_point( $embeddings, $data ) { return 'completed' === $response['result']['status']; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - } - - return new \WP_Error( 'collection_error', $e->getMessage() ); + return $this->handle_exception( $e ); } } @@ -256,11 +262,7 @@ public function add_points( $points ) { return 'completed' === $response['result']['status']; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - } - - return new \WP_Error( 'collection_error', $e->getMessage() ); + return $this->handle_exception( $e ); } } @@ -281,11 +283,7 @@ public function delete_point( $id ) { return 'acknowledged' === $response['result']['status']; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - } - - return new \WP_Error( 'collection_error', $e->getMessage() ); + return $this->handle_exception( $e ); } } @@ -328,19 +326,7 @@ function ( $result ) { return $payload; } catch ( \Exception $e ) { - if ( 403 === $e->getCode() ) { - update_option( 'hyve_qdrant_status', 'inactive' ); - update_option( - self::ERROR_OPTION_KEY, - [ - 'code' => $e->getCode(), - 'date' => wp_date( 'c' ), - 'provider' => 'Qdrant', - ] - ); - } - - return new \WP_Error( 'collection_error', $e->getMessage() ); + return $this->handle_exception( $e ); } } @@ -429,11 +415,66 @@ public function migrate_data() { } } + /** + * Persist a Qdrant service error and reconcile the connection status. + * + * Surfaces the failure in the dashboard notice and, for errors that mean + * the integration is no longer usable (revoked key, or a missing collection + * or cluster), marks the connection inactive so the UI stops reporting a + * connection that no longer works. + * + * @since 1.4.2 + * + * @param \Exception $e The caught exception. + * + * @return \WP_Error + */ + private function handle_exception( $e ) { + $code = $e->getCode(); + + update_option( + self::ERROR_OPTION_KEY, + [ + 'code' => $code, + 'message' => $e->getMessage(), + 'date' => wp_date( 'c' ), + 'provider' => 'Qdrant', + ] + ); + + if ( in_array( $code, [ 401, 403, 404 ], true ) ) { + update_option( 'hyve_qdrant_status', 'inactive' ); + } + + return new \WP_Error( 'collection_error', $e->getMessage() ); + } + + /** + * Get an actionable message for a Qdrant error code. + * + * @since 1.4.2 + * + * @param int|string $code The error code. + * + * @return string|null The message, or null when the code is unmapped. + */ + public static function get_error_message_for_code( $code ) { + $credentials = __( 'Hyve could not authenticate with Qdrant. Please check your API key and endpoint URL in the Integrations settings.', 'hyve-lite' ); + + $messages = [ + 401 => $credentials, + 403 => $credentials, + 404 => __( 'Your Qdrant collection or cluster could not be reached. It may have been deleted or paused. Please check your Qdrant instance and reconnect.', 'hyve-lite' ), + ]; + + return isset( $messages[ $code ] ) ? $messages[ $code ] : null; + } + /** * Qdrant Status. - * + * * @since 1.3.0 - * + * * @return bool */ public static function is_active() { diff --git a/migrations/20260704000000_ensure_qdrant_post_id_index.php b/migrations/20260704000000_ensure_qdrant_post_id_index.php new file mode 100644 index 0000000..91bb77b --- /dev/null +++ b/migrations/20260704000000_ensure_qdrant_post_id_index.php @@ -0,0 +1,48 @@ +ensure_payload_index(); + + if ( is_wp_error( $result ) ) { + throw new \Exception( esc_html( $result->get_error_message() ) ); + } + } +}; diff --git a/patches/qdrant-response-returntypewillchange.patch b/patches/qdrant-response-returntypewillchange.patch new file mode 100644 index 0000000..e906624 --- /dev/null +++ b/patches/qdrant-response-returntypewillchange.patch @@ -0,0 +1,10 @@ +--- a/src/Response.php ++++ b/src/Response.php +@@ -45,6 +45,7 @@ + return isset($this->raw[$offset]); + } + ++ #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->raw[$offset]; diff --git a/src/backend/index.js b/src/backend/index.js index 57d7b74..4b576c1 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -1,8 +1,12 @@ /** * WordPress dependencies. */ +import apiFetch from '@wordpress/api-fetch'; + import domReady from '@wordpress/dom-ready'; +import { dispatch } from '@wordpress/data'; + import { createRoot } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; @@ -17,6 +21,25 @@ import PostModal from './parts/PostModal'; import { getChatIcons } from './utils'; import { OthersSection } from './parts/data/OthersSection'; +// Keep the dashboard service-error notice in sync with every settings save, +// regardless of which component (lite or pro) triggers it. The settings +// endpoint returns the freshly-computed service errors on each save. +apiFetch.use( async ( options, next ) => { + const response = await next( options ); + + if ( + 'POST' === options.method && + 'string' === typeof options.path && + options.path.includes( '/settings' ) && + response && + Array.isArray( response.serviceErrors ) + ) { + dispatch( 'hyve' ).setServiceErrors( response.serviceErrors ); + } + + return response; +} ); + window.hyveComponents = {}; window.hyveComponents.PostsTable = PostsTable; diff --git a/src/backend/parts/ErrorSection.js b/src/backend/parts/ErrorSection.js index a042680..32bcb65 100644 --- a/src/backend/parts/ErrorSection.js +++ b/src/backend/parts/ErrorSection.js @@ -4,8 +4,14 @@ import { Notice } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; export const ErrorSection = () => { + const serviceErrors = useSelect( + ( select ) => select( 'hyve' ).getServiceErrors(), + [] + ); + const instructionMessage = __( 'Please test the chat after solving the problem.', 'hyve-lite' ) + ' ' + @@ -16,28 +22,26 @@ export const ErrorSection = () => { return ( <> - { window.hyve?.serviceErrors?.map( - ( { provider, date, message, code } ) => { - return ( - - - [{ provider }]{ ' ' } - { __( 'Service Error', 'hyve-lite' ) }: - { ' ' } - { message } ({ code }) |{ ' ' } - { new Date( date ).toLocaleString() } -
-
- { instructionMessage } -
- ); - } - ) } + { serviceErrors?.map( ( { provider, date, message, code } ) => { + return ( + + + [{ provider }]{ ' ' } + { __( 'Service Error', 'hyve-lite' ) }: + { ' ' } + { message } ({ code }) |{ ' ' } + { new Date( date ).toLocaleString() } +
+
+ { instructionMessage } +
+ ); + } ) } ); }; diff --git a/src/backend/parts/PostsTable.js b/src/backend/parts/PostsTable.js index 551983e..0d057f0 100644 --- a/src/backend/parts/PostsTable.js +++ b/src/backend/parts/PostsTable.js @@ -1,7 +1,7 @@ /** * WordPress dependencies. */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { Button, Spinner } from '@wordpress/components'; @@ -38,6 +38,19 @@ export const PostsTable = ( { { post.title } + + { post.error && ( + + { sprintf( + // translators: %s: the reason indexing failed, including whether it will be retried. + __( + 'Indexing failed: %s', + 'hyve-lite' + ), + post.error + ) } + + ) }
diff --git a/src/backend/parts/settings/Advanced.js b/src/backend/parts/settings/Advanced.js index 65cabc5..179254a 100644 --- a/src/backend/parts/settings/Advanced.js +++ b/src/backend/parts/settings/Advanced.js @@ -64,10 +64,17 @@ const Advanced = () => { setApiStatus( 'none' ); } - createNotice( 'success', __( 'Settings saved.', 'hyve-lite' ), { - type: 'snackbar', - isDismissible: true, - } ); + if ( response.warning ) { + createNotice( 'warning', response.warning, { + type: 'snackbar', + isDismissible: true, + } ); + } else { + createNotice( 'success', __( 'Settings saved.', 'hyve-lite' ), { + type: 'snackbar', + isDismissible: true, + } ); + } } catch ( error ) { if ( settings.api_key ) { setApiStatus( 'error' ); diff --git a/src/backend/store.js b/src/backend/store.js index 5486b8d..43e9049 100644 --- a/src/backend/store.js +++ b/src/backend/store.js @@ -11,6 +11,7 @@ const DEFAULT_STATE = { hasAPI: Boolean( window.hyve.hasAPIKey ), isQdrantActive: Boolean( window.hyve.isQdrantActive ), totalChunks: 0, + serviceErrors: window.hyve.serviceErrors || [], }; const actions = { @@ -56,6 +57,12 @@ const actions = { isQdrantActive, }; }, + setServiceErrors( serviceErrors ) { + return { + type: 'SET_SERVICE_ERRORS', + serviceErrors, + }; + }, }; const selectors = { @@ -83,6 +90,9 @@ const selectors = { isQdrantActive( state ) { return state.isQdrantActive; }, + getServiceErrors( state ) { + return state.serviceErrors; + }, }; const reducer = ( state = DEFAULT_STATE, action ) => { @@ -125,6 +135,11 @@ const reducer = ( state = DEFAULT_STATE, action ) => { ...state, isQdrantActive: action.isQdrantActive, }; + case 'SET_SERVICE_ERRORS': + return { + ...state, + serviceErrors: action.serviceErrors, + }; default: return state; } diff --git a/src/backend/utils.js b/src/backend/utils.js index dd27750..7225df7 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -152,10 +152,24 @@ export const onProcessData = async ( { throw response; } - createNotice( 'success', __( 'Post has been updated.', 'hyve-lite' ), { - type: 'snackbar', - isDismissible: true, - } ); + if ( response.warning ) { + // Content was added, but indexing reported a problem (e.g. rate + // limit, no credits). Surface it immediately instead of a plain + // success notice. + createNotice( 'warning', response.warning, { + type: 'snackbar', + isDismissible: true, + } ); + } else { + createNotice( + 'success', + __( 'Post has been updated.', 'hyve-lite' ), + { + type: 'snackbar', + isDismissible: true, + } + ); + } onSuccess(); } catch ( error ) { diff --git a/tests/e2e/specs/dashboard.spec.js b/tests/e2e/specs/dashboard.spec.js index 95b8c59..5ab7fb9 100644 --- a/tests/e2e/specs/dashboard.spec.js +++ b/tests/e2e/specs/dashboard.spec.js @@ -449,9 +449,10 @@ test.describe( 'Dashboard', () => { } ); test( 'check service error rendering', async ( { page, admin } ) => { + // Service errors are held in the data store and rendered reactively, so + // inject them via dispatch rather than mutating window.hyve. await page.evaluate( () => { - window.hyve = window.hyve || {}; - window.hyve.serviceErrors = [ + window.wp.data.dispatch( 'hyve' ).setServiceErrors( [ { code: 'invalid_api_key', message: @@ -466,7 +467,7 @@ test.describe( 'Dashboard', () => { date: '2025-06-05T14:57:31+00:00', provider: 'Qdrant', }, - ]; + ] ); } ); // Trigger the rendering via React tree refresh. diff --git a/tests/php/unit/tests/test-qdrant.php b/tests/php/unit/tests/test-qdrant.php new file mode 100644 index 0000000..3f0c570 --- /dev/null +++ b/tests/php/unit/tests/test-qdrant.php @@ -0,0 +1,89 @@ +setAccessible( true ); + + return $method->invoke( $qdrant, new Exception( $message, $code ) ); + } + + /** + * Clean up options between tests. + */ + protected function tearDown(): void { + delete_option( Qdrant_API::ERROR_OPTION_KEY ); + delete_option( 'hyve_qdrant_status' ); + parent::tearDown(); + } + + /** + * A missing collection/cluster (404) persists the error and marks the + * connection inactive so the UI stops reporting a false connection. + */ + public function test_not_found_persists_error_and_deactivates() { + update_option( 'hyve_qdrant_status', 'active' ); + + $result = $this->handle( 404, 'Not Found' ); + $saved = get_option( Qdrant_API::ERROR_OPTION_KEY ); + + $this->assertWPError( $result ); + $this->assertEquals( 404, $saved['code'] ); + $this->assertEquals( 'Qdrant', $saved['provider'] ); + $this->assertEquals( 'inactive', get_option( 'hyve_qdrant_status' ) ); + } + + /** + * Revoked credentials (403) also deactivate the connection. + */ + public function test_forbidden_deactivates() { + update_option( 'hyve_qdrant_status', 'active' ); + + $this->handle( 403 ); + + $this->assertEquals( 'inactive', get_option( 'hyve_qdrant_status' ) ); + } + + /** + * Transient errors (e.g. 500) surface in the dashboard but must NOT + * disconnect a working integration. + */ + public function test_transient_error_persists_but_keeps_status() { + update_option( 'hyve_qdrant_status', 'active' ); + + $this->handle( 500, 'Server error' ); + $saved = get_option( Qdrant_API::ERROR_OPTION_KEY ); + + $this->assertEquals( 500, $saved['code'] ); + $this->assertEquals( 'active', get_option( 'hyve_qdrant_status' ) ); + } + + /** + * Known codes map to actionable messages; unknown codes return null. + */ + public function test_error_message_for_code() { + $this->assertNotNull( Qdrant_API::get_error_message_for_code( 403 ) ); + $this->assertNotNull( Qdrant_API::get_error_message_for_code( 404 ) ); + $this->assertNull( Qdrant_API::get_error_message_for_code( 500 ) ); + } +}