';
+ 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 '
';
+ foreach ( $counts as $code => $count ) {
+ echo '- ';
+ printf(
+ /* translators: 1: number of failures, 2: human-readable reason. */
+ esc_html__( '%1$d × %2$s', 'wpvdb' ),
+ (int) $count,
+ esc_html( self::embedding_error_label( $code ) )
+ );
+ if ( ! empty( $samples[ $code ] ) ) {
+ echo ' —
' . esc_html( $samples[ $code ] ) . '';
+ }
+ echo ' ';
+ }
+ 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' );
}