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 ) {
@@ -194,7 +197,7 @@
class="regular-text">
- https://platform.openai.com/api-keys
+ https://platform.openai.com/api-keys
@@ -213,7 +216,7 @@ class="regular-text">
-
+
@@ -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..0c0bfa0 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'] ) ) {
@@ -1506,14 +1521,55 @@ public function ajax_bulk_embed() {
);
}
+ $failures_before = get_transient( 'wpvdb_embedding_failures' );
+ $failures_before = is_array( $failures_before ) ? count( $failures_before ) : 0;
+
$queue->save()->dispatch();
+ // Determine the outcome from recorded failures, not chunk counts.
+ $recorded = get_transient( 'wpvdb_embedding_failures' );
+ $new_failures = ( is_array( $recorded ) && count( $recorded ) > $failures_before )
+ ? array_slice( $recorded, $failures_before )
+ : array();
+
+ $requested = array_flip( $post_ids );
+ $failed_ids = array();
+ $reason = '';
+ foreach ( $new_failures as $entry ) {
+ if ( isset( $entry['post_id'], $requested[ $entry['post_id'] ] ) ) {
+ $failed_ids[] = (int) $entry['post_id'];
+ if ( '' === $reason && ! empty( $entry['message'] ) ) {
+ $reason = (string) $entry['message'];
+ }
+ }
+ }
+ $failed_ids = array_values( array_unique( $failed_ids ) );
+
+ if ( ! empty( $failed_ids ) ) {
+ $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_ids ), 'wpvdb' ),
+ count( $failed_ids ),
+ $reason
+ );
+
+ wp_send_json_error(
+ array(
+ 'message' => $failed_message,
+ 'failed_ids' => $failed_ids,
+ )
+ );
+ }
+
+ $success_message = sprintf(
+ /* translators: %d: number of posts queued for embedding. */
+ _n( '%d post queued for embedding.', '%d posts queued for embedding.', count( $post_ids ), 'wpvdb' ),
+ count( $post_ids )
+ );
+
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 +2187,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 ' ';
+ 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..1bdcc99 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,36 @@ 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();
+ }
+
+ $message = (string) $error->get_error_message();
+ $message = function_exists( 'mb_substr' ) ? mb_substr( $message, 0, 200 ) : substr( $message, 0, 200 );
+
+ $failures[] = array(
+ 'post_id' => (int) $post_id,
+ 'provider' => (string) $provider,
+ 'code' => $error->get_error_code(),
+ 'message' => $message,
+ );
+
+ // 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..25c5ce3 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,16 +146,39 @@ 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;
}
$provider_settings = $input[ $provider ];
- // Validate and encrypt API key.
- if ( ! empty( $provider_settings['api_key'] ) ) {
- $validated[ $provider ]['api_key'] = self::encrypt_api_key( $provider_settings['api_key'] );
+ // Validate and encrypt API key. Ignore non-string values (e.g. a malformed array payload).
+ $raw_api_key = isset( $provider_settings['api_key'] ) && is_string( $provider_settings['api_key'] )
+ ? $provider_settings['api_key']
+ : '';
+ if ( '' !== $raw_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 ) ) {
+ $provider_data = Providers::get_provider( $provider );
+ $provider_label = ( is_array( $provider_data ) && ! empty( $provider_data['label'] ) ) ? $provider_data['label'] : $provider;
+ 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_label,
+ $key_error->get_error_message()
+ ),
+ 'error'
+ );
+ }
+ }
}
// Validate API base URL.
@@ -187,6 +215,38 @@ 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 ) {
+ $submitted_base = isset( $provider_settings['api_base'] ) && is_string( $provider_settings['api_base'] )
+ ? $provider_settings['api_base']
+ : '';
+ $api_base = '' !== $submitted_base
+ ? self::normalize_api_base_for_provider( $provider, $submitted_base )
+ : self::get_api_base_for_provider( $provider );
+
+ $submitted_model = isset( $provider_settings['default_model'] ) && is_string( $provider_settings['default_model'] )
+ ? $provider_settings['default_model']
+ : '';
+ $model = '' !== $submitted_model
+ ? $submitted_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 +474,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 +573,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 +611,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.
*/
|