diff --git a/admin/views/embeddings.php b/admin/views/embeddings.php index dc9eef6..4021c3f 100644 --- a/admin/views/embeddings.php +++ b/admin/views/embeddings.php @@ -498,31 +498,36 @@ function ( $a, $b ) {

+ +
- + +

- +
diff --git a/admin/views/settings.php b/admin/views/settings.php index 270bf15..b53cfc4 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -36,6 +36,8 @@ $openai_api_version = isset( $settings['openai']['api_version'] ) ? $settings['openai']['api_version'] : ''; $automattic_api_key = isset( $settings['automattic']['api_key'] ) ? $settings['automattic']['api_key'] : ''; $automattic_endpoint = isset( $settings['automattic']['api_base'] ) ? $settings['automattic']['api_base'] : \WPVDB\Providers::get_api_base( 'automattic' ); +$voyage_api_key = isset( $settings['voyage']['api_key'] ) ? $settings['voyage']['api_key'] : ''; +$voyage_endpoint = isset( $settings['voyage']['api_base'] ) ? $settings['voyage']['api_base'] : \WPVDB\Providers::get_api_base( 'voyage' ); $embedding_batch_size = isset( $settings['batch_size'] ) ? $settings['batch_size'] : \WPVDB\Settings::DEFAULTS['batch_size']; $provider_model = static function ( $provider_id ) use ( $settings, $provider, $active_model ) { if ( $provider === $provider_id && ! empty( $active_model ) ) { @@ -49,6 +51,7 @@ $openai_model = $provider_model( 'openai' ); $automattic_model = $provider_model( 'automattic' ); $specter_model = $provider_model( 'specter' ); +$voyage_model = $provider_model( 'voyage' ); ?>
@@ -348,6 +351,94 @@ class="regular-text"> + + > + + + + + +

+ + https://dashboard.voyageai.com/ +

+ + + + + > + + + + + +
+

+ +

+

+ %1$s in wp-config.php to a supported value (e.g. 1024), then rebuild your embeddings.', 'wpvdb' ), + array( + 'code' => array(), + 'a' => array( 'href' => array() ), + ) + ), + "define( 'WPVDB_DEFAULT_EMBED_DIM', 1024 );", + esc_url( admin_url( 'admin.php?page=wpvdb-embeddings' ) ) + ); + ?> +

+
+ + +

+ +

+ + + + + > + + + + + +

+ +

+ + + diff --git a/assets/js/admin.js b/assets/js/admin.js index 1ea473f..ce6e096 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -180,7 +180,7 @@ jQuery(document).ready(function($) { response.data.embedding_count + ' existing embeddings. Continue?')) { console.log('WPVDB: User confirmed provider change'); // User confirmed, submit the form - $('#wpvdb-settings-form').off('submit').trigger('submit'); + HTMLFormElement.prototype.submit.call($('#wpvdb-settings-form').off('submit').get(0)); } else { console.log('WPVDB: User cancelled provider change'); // User cancelled, reset the form @@ -190,7 +190,7 @@ jQuery(document).ready(function($) { } else { console.log('WPVDB: No embeddings exist, proceeding with provider change'); // No embeddings exist, just submit the form - $('#wpvdb-settings-form').off('submit').trigger('submit'); + HTMLFormElement.prototype.submit.call($('#wpvdb-settings-form').off('submit').get(0)); } } else { console.error('WPVDB: Provider change validation error:', response.data.message); diff --git a/includes/class-wpvdb-admin.php b/includes/class-wpvdb-admin.php index 08df5d4..b9b0e53 100644 --- a/includes/class-wpvdb-admin.php +++ b/includes/class-wpvdb-admin.php @@ -312,6 +312,12 @@ public function validate_settings( $input ) { if ( isset( $input['specter']['api_base'] ) ) { $input['specter']['api_base'] = sanitize_text_field( $input['specter']['api_base'] ); } + if ( isset( $input['voyage']['api_key'] ) ) { + $input['voyage']['api_key'] = Settings::encrypt_api_key( sanitize_text_field( $input['voyage']['api_key'] ) ); + } + if ( isset( $input['voyage']['api_base'] ) ) { + $input['voyage']['api_base'] = sanitize_text_field( $input['voyage']['api_base'] ); + } if ( isset( $input['active_provider'] ) ) { $input['active_provider'] = sanitize_text_field( $input['active_provider'] ); // For backwards compatibility. @@ -332,6 +338,9 @@ public function validate_settings( $input ) { if ( isset( $input['specter']['default_model'] ) ) { $input['specter']['default_model'] = sanitize_text_field( $input['specter']['default_model'] ); } + if ( isset( $input['voyage']['default_model'] ) ) { + $input['voyage']['default_model'] = sanitize_text_field( $input['voyage']['default_model'] ); + } // Update individual options for backwards compatibility. if ( isset( $input['openai']['api_key'] ) ) { @@ -359,6 +368,9 @@ public function validate_settings( $input ) { if ( ! isset( $input['specter'] ) || ! is_array( $input['specter'] ) ) { $input['specter'] = isset( $current_settings['specter'] ) && is_array( $current_settings['specter'] ) ? $current_settings['specter'] : array(); } + if ( ! isset( $input['voyage'] ) || ! is_array( $input['voyage'] ) ) { + $input['voyage'] = isset( $current_settings['voyage'] ) && is_array( $current_settings['voyage'] ) ? $current_settings['voyage'] : array(); + } // Make sure api_key and default_model at least exist (even if empty). if ( ! isset( $input['openai']['api_key'] ) ) { @@ -376,6 +388,9 @@ public function validate_settings( $input ) { if ( ! isset( $input['specter']['default_model'] ) ) { $input['specter']['default_model'] = $this->get_default_model( 'specter' ); } + if ( ! isset( $input['voyage']['default_model'] ) ) { + $input['voyage']['default_model'] = $this->get_default_model( 'voyage' ); + } // Make sure post_types is always an array. if ( isset( $input['post_types'] ) && ! is_array( $input['post_types'] ) ) { @@ -1508,12 +1523,50 @@ public function ajax_bulk_embed() { $queue->save()->dispatch(); + // Report the real outcome instead of treating "queued" as success: the queue + // runs inline in the admin request, so chunk counts reflect what actually persisted. + $succeeded = array(); + $failed = array(); + foreach ( $post_ids as $post_id ) { + if ( (int) get_post_meta( $post_id, '_wpvdb_chunks_count', true ) > 0 ) { + $succeeded[] = $post_id; + } else { + $failed[] = $post_id; + } + } + + if ( ! empty( $failed ) ) { + $recorded = get_transient( 'wpvdb_embedding_failures' ); + $reason = ''; + if ( is_array( $recorded ) && ! empty( $recorded ) ) { + $entry = end( $recorded ); + $reason = isset( $entry['message'] ) ? (string) $entry['message'] : ''; + } + + $failed_message = sprintf( + /* translators: 1: number of posts that failed, 2: failure reason from the provider. */ + _n( '%1$d post failed to embed: %2$s', '%1$d posts failed to embed: %2$s', count( $failed ), 'wpvdb' ), + count( $failed ), + $reason + ); + + wp_send_json_error( + array( + 'message' => $failed_message, + 'failed_ids' => $failed, + ) + ); + } + + $success_message = sprintf( + /* translators: %d: number of posts successfully embedded. */ + _n( '%d post embedded successfully.', '%d posts embedded successfully.', count( $succeeded ), 'wpvdb' ), + count( $succeeded ) + ); + wp_send_json_success( array( - 'message' => sprintf( - __( 'Queued %d posts for embedding generation', 'wpvdb' ), - count( $post_ids ) - ), + 'message' => $success_message, 'using_pending' => $using_pending, ) ); @@ -2131,10 +2184,74 @@ public function handle_admin_actions() { } } + /** + * Human-readable label for an embedding error code. + * + * @param string $code WP_Error code from the embedding pipeline. + * @return string + */ + private static function embedding_error_label( $code ) { + switch ( $code ) { + case 'embedding_auth_error': + return __( 'authentication failed (check the API key under Settings)', 'wpvdb' ); + case 'embedding_forbidden': + return __( 'access forbidden by the provider', 'wpvdb' ); + case 'embedding_rate_limited': + return __( 'rate limited by the provider (HTTP 429)', 'wpvdb' ); + case 'embedding_model_not_found': + return __( 'model not found', 'wpvdb' ); + case 'embedding_provider_error': + return __( 'provider server error', 'wpvdb' ); + default: + return __( 'embedding error', 'wpvdb' ); + } + } + /** * Display admin notices for action results */ public function admin_notices() { + // Surface embedding failures captured during background processing so they are never lost silently. + $embedding_failures = get_transient( 'wpvdb_embedding_failures' ); + if ( is_array( $embedding_failures ) && ! empty( $embedding_failures ) ) { + delete_transient( 'wpvdb_embedding_failures' ); + + $counts = array(); + $samples = array(); + foreach ( $embedding_failures as $failure ) { + $code = isset( $failure['code'] ) ? (string) $failure['code'] : 'embedding_error'; + $counts[ $code ] = isset( $counts[ $code ] ) ? $counts[ $code ] + 1 : 1; + if ( ! isset( $samples[ $code ] ) && ! empty( $failure['message'] ) ) { + $samples[ $code ] = (string) $failure['message']; + } + } + + $total = count( $embedding_failures ); + echo '

'; + printf( + /* translators: %d: number of failed embeddings. */ + esc_html( _n( 'WPVDB: %d embedding failed and was not saved.', 'WPVDB: %d embeddings failed and were not saved.', $total, 'wpvdb' ) ), + (int) $total + ); + echo '

'; + esc_html_e( 'See the debug log for full details, then re-run “Bulk Generate Embeddings” for the affected items.', 'wpvdb' ); + echo '

'; + } + // Check for table recreation status. $recreate_status = get_transient( 'wpvdb_table_recreate_status' ); if ( $recreate_status ) { diff --git a/includes/class-wpvdb-core.php b/includes/class-wpvdb-core.php index dab8b70..07f76b0 100644 --- a/includes/class-wpvdb-core.php +++ b/includes/class-wpvdb-core.php @@ -291,7 +291,20 @@ public static function get_embedding( $text, $model, $api_base, $api_key ) { } if ( 200 !== $code ) { - return new \WP_Error( 'embedding_error', 'Failed to get embedding: ' . $code . ' ' . ( is_string( $data ) ? $data : wp_json_encode( $data ) ) ); + $status_code = (int) $code; + $error_code = 'embedding_error'; + if ( 401 === $status_code ) { + $error_code = 'embedding_auth_error'; + } elseif ( 403 === $status_code ) { + $error_code = 'embedding_forbidden'; + } elseif ( 404 === $status_code ) { + $error_code = 'embedding_model_not_found'; + } elseif ( 429 === $status_code ) { + $error_code = 'embedding_rate_limited'; + } elseif ( $status_code >= 500 && $status_code < 600 ) { + $error_code = 'embedding_provider_error'; + } + return new \WP_Error( $error_code, 'Failed to get embedding: ' . $code . ' ' . ( is_string( $data ) ? $data : wp_json_encode( $data ) ) ); } if ( 'a8c_nomic_native' === $response_format ) { @@ -446,13 +459,15 @@ private static function get_embedding_custom_options( $model, $api_base ) { return array(); } - // Only send dimensions to models that explicitly support it. + // Only send dimensions to models that explicitly support it, under the + // parameter name the provider expects (OpenAI: dimensions, Voyage: output_dimension). + $dimension_param = Models::get_dimension_param( $model, $api_base ); if ( - ! isset( $options['dimensions'] ) && + ! isset( $options[ $dimension_param ] ) && defined( 'WPVDB_DEFAULT_EMBED_DIM' ) && Models::supports_dimensions( $model, $api_base ) ) { - $options['dimensions'] = (int) WPVDB_DEFAULT_EMBED_DIM; + $options[ $dimension_param ] = (int) WPVDB_DEFAULT_EMBED_DIM; } return array_filter( diff --git a/includes/class-wpvdb-models.php b/includes/class-wpvdb-models.php index e8c4e12..4403da9 100644 --- a/includes/class-wpvdb-models.php +++ b/includes/class-wpvdb-models.php @@ -99,6 +99,62 @@ public static function get_available_models() { 'supports_dimensions' => false, ), ), + 'voyage' => array( + 'voyage-4-lite' => array( + 'label' => 'Voyage 4 Lite (256/512/1024/2048 dim)', + 'dimensions' => 1024, + 'default' => true, + 'selectable' => true, + 'endpoint' => 'embeddings', + 'request_format' => 'openai', + 'response_format' => 'openai', + 'supports_dimensions' => true, + 'allowed_dimensions' => array( 256, 512, 1024, 2048 ), + 'dimension_param' => 'output_dimension', + ), + 'voyage-4-large' => array( + 'label' => 'Voyage 4 Large (256/512/1024/2048 dim)', + 'dimensions' => 1024, + 'selectable' => true, + 'endpoint' => 'embeddings', + 'request_format' => 'openai', + 'response_format' => 'openai', + 'supports_dimensions' => true, + 'allowed_dimensions' => array( 256, 512, 1024, 2048 ), + 'dimension_param' => 'output_dimension', + ), + 'voyage-code-3' => array( + 'label' => 'Voyage Code 3 (256/512/1024/2048 dim)', + 'dimensions' => 1024, + 'selectable' => true, + 'endpoint' => 'embeddings', + 'request_format' => 'openai', + 'response_format' => 'openai', + 'supports_dimensions' => true, + 'allowed_dimensions' => array( 256, 512, 1024, 2048 ), + 'dimension_param' => 'output_dimension', + ), + 'voyage-finance-2' => array( + 'label' => 'Voyage Finance 2 (1024 dim)', + 'dimensions' => 1024, + 'selectable' => true, + 'endpoint' => 'embeddings', + 'request_format' => 'openai', + 'response_format' => 'openai', + 'supports_dimensions' => false, + 'dimension_param' => 'output_dimension', + ), + 'voyage-law-2' => array( + 'label' => 'Voyage Law 2 (1024 dim)', + 'dimensions' => 1024, + 'selectable' => true, + 'endpoint' => 'embeddings', + 'request_format' => 'openai', + 'response_format' => 'openai', + 'supports_dimensions' => false, + 'dimension_param' => 'output_dimension', + ), + ), ); // Allow plugins to register additional models or modify existing ones. @@ -258,6 +314,20 @@ public static function supports_dimensions( $model_name, $api_base = '' ) { return $model && ! empty( $model['supports_dimensions'] ); } + /** + * Get the request body parameter name used to request an output dimension. + * + * OpenAI-compatible APIs use `dimensions`; Voyage AI uses `output_dimension`. + * + * @param string $model_name Model name. + * @param string $api_base API base URL. + * @return string Parameter name + */ + public static function get_dimension_param( $model_name, $api_base = '' ) { + $model = self::get_model_for_request( $model_name, $api_base ); + return ( $model && ! empty( $model['dimension_param'] ) ) ? $model['dimension_param'] : 'dimensions'; + } + /** * Check whether a model can produce embeddings for the storage dimension. * @@ -274,6 +344,42 @@ public static function is_storage_compatible( $model_name, $api_base = '', $targ return self::is_model_storage_compatible( $model, self::get_storage_dimension( $target_dim ) ); } + /** + * Get the configured embedding column dimension. + * + * @return int Storage dimension + */ + public static function get_configured_dimension() { + return self::get_storage_dimension(); + } + + /** + * Get the embedding dimensions a provider's models can produce. + * + * Returns the discrete set of storage dimensions for which at least one of + * the provider's models would be selectable. An empty array means the + * provider has a model that accepts any dimension (no constraint to surface). + * + * @param string $provider Provider name. + * @return int[] Sorted, unique list of compatible dimensions + */ + public static function get_provider_compatible_dimensions( $provider ) { + $dims = array(); + foreach ( self::get_provider_models( $provider ) as $model ) { + if ( isset( $model['allowed_dimensions'] ) && is_array( $model['allowed_dimensions'] ) ) { + $dims = array_merge( $dims, array_map( 'intval', $model['allowed_dimensions'] ) ); + } elseif ( ! empty( $model['supports_dimensions'] ) ) { + // Model accepts any dimension; the provider is never dimension-constrained. + return array(); + } elseif ( isset( $model['dimensions'] ) ) { + $dims[] = (int) $model['dimensions']; + } + } + $dims = array_values( array_unique( $dims ) ); + sort( $dims ); + return $dims; + } + /** * Get default model for a provider * @@ -314,6 +420,10 @@ private static function guess_provider_from_api_base( $api_base ) { return 'openai'; } + if ( strpos( $api_base, 'api.voyageai.com' ) !== false ) { + return 'voyage'; + } + return ''; } @@ -338,6 +448,9 @@ private static function get_storage_dimension( $target_dim = null ) { * @return bool Whether the model can fit the configured embedding column */ private static function is_model_storage_compatible( $model, $target_dim ) { + if ( isset( $model['allowed_dimensions'] ) && is_array( $model['allowed_dimensions'] ) ) { + return in_array( $target_dim, array_map( 'intval', $model['allowed_dimensions'] ), true ); + } if ( ! empty( $model['supports_dimensions'] ) ) { return true; } diff --git a/includes/class-wpvdb-providers.php b/includes/class-wpvdb-providers.php index ac0f2db..5fc8846 100644 --- a/includes/class-wpvdb-providers.php +++ b/includes/class-wpvdb-providers.php @@ -47,6 +47,13 @@ public static function get_available_providers() { 'api_key_constant' => '', // No API key needed for local server. 'description' => __( 'SPECTER2 is a research model for scientific document embeddings, running locally.', 'wpvdb' ), ), + 'voyage' => array( + 'name' => 'voyage', + 'label' => 'Voyage AI', + 'api_base' => 'https://api.voyageai.com/v1/', + 'api_key_constant' => 'WPVDB_VOYAGE_API_KEY', + 'description' => __( 'Voyage AI provides domain-specialized embedding models for code, finance, and law.', 'wpvdb' ), + ), ); // Allow plugins to register additional providers. diff --git a/includes/class-wpvdb-queue.php b/includes/class-wpvdb-queue.php index 6001d8a..9bf72bd 100644 --- a/includes/class-wpvdb-queue.php +++ b/includes/class-wpvdb-queue.php @@ -490,6 +490,7 @@ private static function process_post( $post, $model, $provider = '' ) { // Get embedding. $embedding_result = Core::get_embedding( $chunk, $model, $api_base, $api_key ); if ( is_wp_error( $embedding_result ) ) { + self::record_embedding_failure( $post->ID, $provider, $embedding_result ); Core::log_error( 'Failed to generate embedding', array( @@ -514,6 +515,7 @@ private static function process_post( $post, $model, $provider = '' ) { ); if ( is_wp_error( $result ) ) { + self::record_embedding_failure( $post->ID, $provider, $result ); Core::log_error( 'Failed to insert embedding', array( @@ -536,4 +538,33 @@ private static function process_post( $post, $model, $provider = '' ) { return $successful_chunks > 0; } + + /** + * Record an embedding failure so it can be surfaced to the user as an admin notice. + * + * @param int $post_id Post that failed. + * @param string $provider Provider used for the attempt. + * @param \WP_Error $error Failure returned by the embedding pipeline. + * @return void + */ + private static function record_embedding_failure( $post_id, $provider, $error ) { + $failures = get_transient( 'wpvdb_embedding_failures' ); + if ( ! is_array( $failures ) ) { + $failures = array(); + } + + $failures[] = array( + 'post_id' => (int) $post_id, + 'provider' => (string) $provider, + 'code' => $error->get_error_code(), + 'message' => mb_substr( (string) $error->get_error_message(), 0, 200 ), + ); + + // Cap the list so a large failed batch cannot bloat the options table. + if ( count( $failures ) > 50 ) { + $failures = array_slice( $failures, -50 ); + } + + set_transient( 'wpvdb_embedding_failures', $failures, HOUR_IN_SECONDS ); + } } diff --git a/includes/class-wpvdb-settings.php b/includes/class-wpvdb-settings.php index 3cefe0a..9f2daf8 100644 --- a/includes/class-wpvdb-settings.php +++ b/includes/class-wpvdb-settings.php @@ -36,6 +36,10 @@ class Settings { 'api_key' => '', 'api_base' => '', ), + 'voyage' => array( + 'api_key' => '', + 'api_base' => '', + ), ); /** @@ -51,6 +55,7 @@ public static function get_defaults() { $defaults['default_model'] = Models::get_default_model_for_provider( $defaults['active_provider'] ); $defaults['openai']['api_base'] = Providers::get_api_base( 'openai' ); $defaults['automattic']['api_base'] = Providers::get_api_base( 'automattic' ); + $defaults['voyage']['api_base'] = Providers::get_api_base( 'voyage' ); return $defaults; } @@ -94,7 +99,7 @@ public static function validate_settings( $input ) { $validated = self::get_defaults(); // Validate active provider. - if ( isset( $input['active_provider'] ) && in_array( $input['active_provider'], array( 'openai', 'automattic' ), true ) ) { + if ( isset( $input['active_provider'] ) && in_array( $input['active_provider'], array( 'openai', 'automattic', 'voyage' ), true ) ) { $validated['active_provider'] = $input['active_provider']; $validated['default_model'] = Models::get_default_model_for_provider( $validated['active_provider'] ); } @@ -141,7 +146,7 @@ public static function validate_settings( $input ) { } // Validate provider settings. - foreach ( array( 'openai', 'automattic' ) as $provider ) { + foreach ( array( 'openai', 'automattic', 'voyage' ) as $provider ) { if ( ! isset( $input[ $provider ] ) || ! is_array( $input[ $provider ] ) ) { continue; } @@ -150,7 +155,26 @@ public static function validate_settings( $input ) { // Validate and encrypt API key. if ( ! empty( $provider_settings['api_key'] ) ) { - $validated[ $provider ]['api_key'] = self::encrypt_api_key( $provider_settings['api_key'] ); + $raw_api_key = $provider_settings['api_key']; + $validated[ $provider ]['api_key'] = self::encrypt_api_key( $raw_api_key ); + + // A plaintext value (not the stored encrypted blob) means the user entered a new key; verify it. + if ( 0 !== strpos( $raw_api_key, 'wpvdb_encrypted_' ) ) { + $key_error = self::validate_provider_api_key( $provider, $raw_api_key, $provider_settings ); + if ( is_wp_error( $key_error ) ) { + add_settings_error( + 'wpvdb_settings', + 'wpvdb_invalid_api_key_' . $provider, + sprintf( + /* translators: 1: provider name, 2: error message returned by the provider. */ + __( 'The %1$s API key was rejected: %2$s', 'wpvdb' ), + $provider, + $key_error->get_error_message() + ), + 'error' + ); + } + } } // Validate API base URL. @@ -187,6 +211,32 @@ public static function validate_settings( $input ) { return $validated; } + /** + * Verify a provider API key by issuing a minimal embedding request. + * + * @param string $provider Provider identifier. + * @param string $api_key Plaintext API key to verify. + * @param array $provider_settings Submitted provider settings (api_base, default_model). + * @return \WP_Error|null WP_Error when the provider rejects the credentials, null otherwise. + */ + private static function validate_provider_api_key( $provider, $api_key, $provider_settings ) { + $api_base = ! empty( $provider_settings['api_base'] ) + ? self::normalize_api_base_for_provider( $provider, $provider_settings['api_base'] ) + : self::get_api_base_for_provider( $provider ); + + $model = ! empty( $provider_settings['default_model'] ) + ? $provider_settings['default_model'] + : Models::get_default_model_for_provider( $provider ); + + $result = Core::get_embedding( 'wpvdb api key check', $model, $api_base, $api_key ); + + if ( is_wp_error( $result ) && in_array( $result->get_error_code(), array( 'embedding_auth_error', 'embedding_forbidden' ), true ) ) { + return $result; + } + + return null; + } + /** * Get validated settings with defaults * @@ -414,6 +464,7 @@ public static function export_settings() { // Remove sensitive information. unset( $settings['openai']['api_key'] ); unset( $settings['automattic']['api_key'] ); + unset( $settings['voyage']['api_key'] ); return array( 'version' => WPVDB_VERSION, @@ -512,6 +563,10 @@ public static function get_api_key() { return \constant( 'WPVDB_AUTOMATTIC_API_KEY' ); } + if ( 'voyage' === $provider && defined( 'WPVDB_VOYAGE_API_KEY' ) ) { + return \constant( 'WPVDB_VOYAGE_API_KEY' ); + } + $encrypted_key = isset( $settings[ $provider ]['api_key'] ) ? $settings[ $provider ]['api_key'] : ''; // If no key in options, check filter. @@ -546,6 +601,10 @@ public static function get_api_key_for_provider( $provider ) { return \constant( 'WPVDB_AUTOMATTIC_API_KEY' ); } + if ( 'voyage' === $provider && defined( 'WPVDB_VOYAGE_API_KEY' ) ) { + return \constant( 'WPVDB_VOYAGE_API_KEY' ); + } + $encrypted_key = ''; // Check in the provider-specific settings. diff --git a/tests/unit/ModelsTest.php b/tests/unit/ModelsTest.php index f13acbc..f98aa35 100644 --- a/tests/unit/ModelsTest.php +++ b/tests/unit/ModelsTest.php @@ -243,6 +243,51 @@ public function test_model_request_metadata_does_not_cross_known_providers() { $this->assertEquals( 'openai', Models::get_request_format( 'nomic-embed-text-v2-moe', 'https://api.openai.com/v1/' ) ); } + /** + * Test that the Voyage AI provider exposes the expected models. + */ + public function test_voyage_provider_models() { + $models = Models::get_provider_models( 'voyage' ); + + $this->assertIsArray( $models ); + foreach ( [ 'voyage-4-lite', 'voyage-4-large', 'voyage-code-3', 'voyage-finance-2', 'voyage-law-2' ] as $model_name ) { + $this->assertArrayHasKey( $model_name, $models ); + $this->assertEquals( 1024, $models[ $model_name ]['dimensions'] ); + } + } + + /** + * Test the provider-specific dimension request parameter name. + */ + public function test_get_dimension_param() { + $voyage_base = 'https://api.voyageai.com/v1/'; + + $this->assertEquals( 'output_dimension', Models::get_dimension_param( 'voyage-4-lite', $voyage_base ) ); + $this->assertEquals( 'output_dimension', Models::get_dimension_param( 'voyage-finance-2', $voyage_base ) ); + $this->assertEquals( 'dimensions', Models::get_dimension_param( 'text-embedding-3-small', 'https://api.openai.com/v1/' ) ); + } + + /** + * Test Voyage storage compatibility against the discrete allowed-dimensions set. + */ + public function test_voyage_storage_compatibility() { + $voyage_base = 'https://api.voyageai.com/v1/'; + + // Flexible (Matryoshka) models accept only their allowed set. + foreach ( [ 256, 512, 1024, 2048 ] as $dim ) { + $this->assertTrue( Models::is_storage_compatible( 'voyage-4-lite', $voyage_base, $dim ) ); + $this->assertTrue( Models::is_storage_compatible( 'voyage-code-3', $voyage_base, $dim ) ); + } + $this->assertFalse( Models::is_storage_compatible( 'voyage-4-lite', $voyage_base, 768 ) ); + $this->assertFalse( Models::is_storage_compatible( 'voyage-4-large', $voyage_base, 1000 ) ); + + // Fixed-dimension models only fit a 1024 column. + $this->assertTrue( Models::is_storage_compatible( 'voyage-finance-2', $voyage_base, 1024 ) ); + $this->assertFalse( Models::is_storage_compatible( 'voyage-finance-2', $voyage_base, 512 ) ); + $this->assertTrue( Models::is_storage_compatible( 'voyage-law-2', $voyage_base, 1024 ) ); + $this->assertFalse( Models::is_storage_compatible( 'voyage-law-2', $voyage_base, 2048 ) ); + } + /** * Test Automattic AI proxy URL detection. */ diff --git a/wpvdb.php b/wpvdb.php index 37f8634..17cc9b8 100644 --- a/wpvdb.php +++ b/wpvdb.php @@ -27,6 +27,8 @@ define( 'WPVDB_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'WPVDB_PLUGIN_FILE', __FILE__ ); +define( 'WPVDB_DEFAULT_EMBED_DIM', 1024 ); + if ( ! defined( 'WPVDB_PLAYGROUND_SUPPORT_VERSION' ) ) { define( 'WPVDB_PLAYGROUND_SUPPORT_VERSION', '1' ); }