Skip to main content

rig_core/providers/
voyageai.rs

1use crate::client::{
2    self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
3    ProviderClient,
4};
5use crate::embeddings;
6use crate::embeddings::EmbeddingError;
7use crate::http_client::{self, HttpClientExt};
8use crate::rerank;
9use crate::rerank::RerankError;
10use bytes::Bytes;
11use serde::Deserialize;
12use serde_json::json;
13
14// ================================================================
15// Main Voyage AI Client
16// ================================================================
17const VOYAGEAI_API_BASE_URL: &str = "https://api.voyageai.com/v1";
18
19#[derive(Debug, Default, Clone, Copy)]
20pub struct VoyageExt;
21
22#[derive(Debug, Default, Clone, Copy)]
23pub struct VoyageBuilder;
24
25type VoyageApiKey = BearerAuth;
26
27impl Provider for VoyageExt {
28    type Builder = VoyageBuilder;
29
30    /// There is currently no way to verify a Voyage api key without consuming tokens
31    const VERIFY_PATH: &'static str = "";
32}
33
34impl<H> Capabilities<H> for VoyageExt {
35    type Completion = Nothing;
36    type Embeddings = Capable<EmbeddingModel<H>>;
37    type Rerank = Capable<RerankModel<H>>;
38    type Transcription = Nothing;
39    type ModelListing = Nothing;
40    #[cfg(feature = "image")]
41    type ImageGeneration = Nothing;
42
43    #[cfg(feature = "audio")]
44    type AudioGeneration = Nothing;
45}
46
47impl DebugExt for VoyageExt {}
48
49impl ProviderBuilder for VoyageBuilder {
50    type Extension<H>
51        = VoyageExt
52    where
53        H: HttpClientExt;
54    type ApiKey = VoyageApiKey;
55
56    const BASE_URL: &'static str = VOYAGEAI_API_BASE_URL;
57
58    fn build<H>(
59        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
60    ) -> http_client::Result<Self::Extension<H>>
61    where
62        H: HttpClientExt,
63    {
64        Ok(VoyageExt)
65    }
66}
67
68pub type Client<H = reqwest::Client> = client::Client<VoyageExt, H>;
69pub type ClientBuilder<H = crate::markers::Missing> =
70    client::ClientBuilder<VoyageBuilder, VoyageApiKey, H>;
71
72impl ProviderClient for Client {
73    type Input = String;
74    type Error = crate::client::ProviderClientError;
75
76    /// Create a new OpenAI client from the `OPENAI_API_KEY` environment variable.
77    fn from_env() -> Result<Self, Self::Error> {
78        let api_key = crate::client::required_env_var("VOYAGE_API_KEY")?;
79        Self::new(&api_key).map_err(Into::into)
80    }
81
82    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
83        Self::new(&input).map_err(Into::into)
84    }
85}
86
87impl<T> EmbeddingModel<T> {
88    pub fn new(client: Client<T>, model: impl Into<String>, ndims: usize) -> Self {
89        Self {
90            client,
91            model: model.into(),
92            ndims,
93        }
94    }
95
96    pub fn with_model(client: Client<T>, model: &str, ndims: usize) -> Self {
97        Self {
98            client,
99            model: model.into(),
100            ndims,
101        }
102    }
103}
104
105// ================================================================
106// Voyage AI Embedding API
107// ================================================================
108
109/// `voyage-3-large` embedding model (Voyage AI)
110pub const VOYAGE_3_LARGE: &str = "voyage-3-large";
111/// `voyage-3.5` embedding model (Voyage AI)
112pub const VOYAGE_3_5: &str = "voyage-3.5";
113/// `voyage-3.5-lite` embedding model (Voyage AI)
114pub const VOYAGE_3_5_LITE: &str = "voyage.3-5.lite";
115/// `voyage-code-3` embedding model (Voyage AI)
116pub const VOYAGE_CODE_3: &str = "voyage-code-3";
117/// `voyage-finance-2` embedding model (Voyage AI)
118pub const VOYAGE_FINANCE_2: &str = "voyage-finance-2";
119/// `voyage-law-2` embedding model (Voyage AI)
120pub const VOYAGE_LAW_2: &str = "voyage-law-2";
121/// `voyage-code-2` embedding model (Voyage AI)
122pub const VOYAGE_CODE_2: &str = "voyage-code-2";
123
124pub fn model_dimensions_from_identifier(model_identifier: &str) -> Option<usize> {
125    match model_identifier {
126        "voyage-code-2" => Some(1536),
127        "voyage-3-large" | "voyage-3.5" | "voyage.3-5.lite" | "voyage-code-3"
128        | "voyage-finance-2" | "voyage-law-2" => Some(1024),
129        _ => None,
130    }
131}
132
133#[derive(Debug, Deserialize)]
134pub struct EmbeddingResponse {
135    pub object: String,
136    pub data: Vec<EmbeddingData>,
137    pub model: String,
138    pub usage: Usage,
139}
140
141#[derive(Clone, Debug, Deserialize)]
142pub struct Usage {
143    pub total_tokens: usize,
144}
145
146#[derive(Debug, Deserialize)]
147pub struct ApiErrorResponse {
148    pub(crate) message: String,
149}
150
151#[derive(Debug, Deserialize)]
152#[serde(untagged)]
153pub(crate) enum ApiResponse<T> {
154    Ok(T),
155    Err(ApiErrorResponse),
156}
157
158#[derive(Debug, Deserialize)]
159pub struct EmbeddingData {
160    pub object: String,
161    pub embedding: Vec<f64>,
162    pub index: usize,
163}
164
165#[derive(Clone)]
166pub struct EmbeddingModel<T> {
167    client: Client<T>,
168    pub model: String,
169    ndims: usize,
170}
171
172impl<T> embeddings::EmbeddingModel for EmbeddingModel<T>
173where
174    T: HttpClientExt + Clone + std::fmt::Debug + Default + 'static,
175{
176    const MAX_DOCUMENTS: usize = 1024;
177
178    type Client = Client<T>;
179
180    fn make(client: &Self::Client, model: impl Into<String>, dims: Option<usize>) -> Self {
181        let model = model.into();
182        let dims = dims
183            .or(model_dimensions_from_identifier(&model))
184            .unwrap_or_default();
185
186        Self::new(client.clone(), model, dims)
187    }
188
189    fn ndims(&self) -> usize {
190        self.ndims
191    }
192
193    async fn embed_texts(
194        &self,
195        documents: impl IntoIterator<Item = String>,
196    ) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
197        let documents: Vec<String> = documents.into_iter().collect();
198        let response = self.embed_texts_with_usage(documents).await?;
199        Ok(response.embeddings)
200    }
201
202    async fn embed_texts_with_usage(
203        &self,
204        documents: impl IntoIterator<Item = String>,
205    ) -> Result<embeddings::EmbeddingResponse, EmbeddingError> {
206        let documents: Vec<String> = documents.into_iter().collect();
207        let request = json!({
208            "model": self.model,
209            "input": documents,
210        });
211
212        let body = serde_json::to_vec(&request)?;
213
214        let req = self
215            .client
216            .post("/embeddings")?
217            .body(body)
218            .map_err(|x| EmbeddingError::HttpError(x.into()))?;
219
220        let response = self.client.send::<_, Bytes>(req).await?;
221        let status = response.status();
222        let response_body = response.into_body().into_future().await?.to_vec();
223
224        if status.is_success() {
225            match serde_json::from_slice::<ApiResponse<EmbeddingResponse>>(&response_body)? {
226                ApiResponse::Ok(response) => {
227                    tracing::info!(target: "rig",
228                        "VoyageAI embedding token usage: {}",
229                        response.usage.total_tokens
230                    );
231
232                    if response.data.len() != documents.len() {
233                        return Err(EmbeddingError::ResponseError(
234                            "Response data length does not match input length".into(),
235                        ));
236                    }
237
238                    let usage = crate::completion::Usage {
239                        input_tokens: response.usage.total_tokens as u64,
240                        output_tokens: 0,
241                        total_tokens: response.usage.total_tokens as u64,
242                        cached_input_tokens: 0,
243                        cache_creation_input_tokens: 0,
244                        tool_use_prompt_tokens: 0,
245                        reasoning_tokens: 0,
246                    };
247
248                    let embeddings = response
249                        .data
250                        .into_iter()
251                        .zip(documents.into_iter())
252                        .map(|(embedding, document)| embeddings::Embedding {
253                            document,
254                            vec: embedding.embedding,
255                        })
256                        .collect();
257
258                    Ok(embeddings::EmbeddingResponse { embeddings, usage })
259                }
260                ApiResponse::Err(err) => {
261                    tracing::warn!(message = %err.message, "provider returned an error response");
262                    Err(EmbeddingError::from_http_response(
263                        status,
264                        String::from_utf8_lossy(&response_body),
265                    ))
266                }
267            }
268        } else {
269            Err(EmbeddingError::from_http_response(
270                status,
271                String::from_utf8_lossy(&response_body),
272            ))
273        }
274    }
275}
276
277// ================================================================
278// Voyage AI Rerank API
279// ================================================================
280
281/// `rerank-2.5` reranker model (Voyage AI)
282pub const RERANK_2_5: &str = "rerank-2.5";
283/// `rerank-2.5-lite` reranker model (Voyage AI)
284pub const RERANK_2_5_LITE: &str = "rerank-2.5-lite";
285/// `rerank-2` reranker model (Voyage AI)
286pub const RERANK_2: &str = "rerank-2";
287/// `rerank-2-lite` reranker model (Voyage AI)
288pub const RERANK_2_LITE: &str = "rerank-2-lite";
289/// `rerank-1` reranker model (Voyage AI)
290pub const RERANK_1: &str = "rerank-1";
291/// `rerank-lite-1` reranker model (Voyage AI)
292pub const RERANK_LITE_1: &str = "rerank-lite-1";
293
294#[derive(Debug, Deserialize)]
295pub struct RerankApiResponse {
296    pub data: Vec<RerankApiData>,
297    pub model: String,
298    pub usage: RerankApiUsage,
299}
300
301#[derive(Debug, Deserialize)]
302pub struct RerankApiUsage {
303    pub total_tokens: usize,
304}
305
306#[derive(Debug, Deserialize)]
307pub struct RerankApiData {
308    pub index: usize,
309    pub relevance_score: f64,
310    #[serde(default)]
311    pub document: Option<String>,
312}
313
314#[derive(Clone)]
315pub struct RerankModel<T = reqwest::Client> {
316    client: Client<T>,
317    pub model: String,
318    pub top_k: Option<usize>,
319    pub return_documents: bool,
320    pub truncation: Option<bool>,
321}
322
323impl<T> RerankModel<T> {
324    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
325        Self {
326            client,
327            model: model.into(),
328            top_k: None,
329            return_documents: false,
330            truncation: None,
331        }
332    }
333
334    pub fn top_k(mut self, top_k: usize) -> Self {
335        self.top_k = Some(top_k);
336        self
337    }
338
339    pub fn return_documents(mut self, return_documents: bool) -> Self {
340        self.return_documents = return_documents;
341        self
342    }
343
344    pub fn truncation(mut self, truncation: bool) -> Self {
345        self.truncation = Some(truncation);
346        self
347    }
348}
349
350impl<T> rerank::RerankModel for RerankModel<T>
351where
352    T: HttpClientExt + Clone + std::fmt::Debug + Default + 'static,
353{
354    const MAX_DOCUMENTS: usize = 1000;
355
356    type Client = Client<T>;
357
358    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
359        Self::new(client.clone(), model)
360    }
361
362    async fn rerank(
363        &self,
364        query: &str,
365        documents: Vec<String>,
366    ) -> Result<rerank::RerankResponse, RerankError> {
367        let mut body = json!({
368            "query": query,
369            "documents": documents,
370            "model": self.model,
371        });
372
373        let body_obj = body.as_object_mut().ok_or_else(|| {
374            RerankError::ResponseError("rerank request body must be a JSON object".into())
375        })?;
376
377        if let Some(top_k) = self.top_k {
378            body_obj.insert("top_k".to_owned(), json!(top_k));
379        }
380
381        body_obj.insert("return_documents".to_owned(), json!(self.return_documents));
382
383        if let Some(truncation) = self.truncation {
384            body_obj.insert("truncation".to_owned(), json!(truncation));
385        }
386
387        let body = serde_json::to_vec(&body)?;
388
389        let req = self
390            .client
391            .post("/rerank")?
392            .body(body)
393            .map_err(|x| RerankError::HttpError(x.into()))?;
394
395        let response = self.client.send::<_, Bytes>(req).await?;
396        let status = response.status();
397        let response_body = response.into_body().into_future().await?.to_vec();
398
399        if status.is_success() {
400            match serde_json::from_slice::<ApiResponse<RerankApiResponse>>(&response_body)? {
401                ApiResponse::Ok(response) => {
402                    tracing::info!(target: "rig",
403                        "VoyageAI rerank token usage: {}",
404                        response.usage.total_tokens
405                    );
406
407                    let usage = crate::completion::Usage {
408                        input_tokens: response.usage.total_tokens as u64,
409                        output_tokens: 0,
410                        total_tokens: response.usage.total_tokens as u64,
411                        cached_input_tokens: 0,
412                        cache_creation_input_tokens: 0,
413                        reasoning_tokens: 0,
414                        tool_use_prompt_tokens: 0,
415                    };
416
417                    let results = response
418                        .data
419                        .into_iter()
420                        .map(|d| rerank::RerankResult {
421                            index: d.index,
422                            document: d.document,
423                            relevance_score: d.relevance_score,
424                        })
425                        .collect();
426
427                    Ok(rerank::RerankResponse {
428                        results,
429                        model: response.model,
430                        usage,
431                    })
432                }
433                ApiResponse::Err(err) => {
434                    tracing::warn!(message = %err.message, "provider returned an error response");
435                    Err(RerankError::from_http_response(
436                        status,
437                        String::from_utf8_lossy(&response_body),
438                    ))
439                }
440            }
441        } else {
442            Err(RerankError::from_http_response(
443                status,
444                String::from_utf8_lossy(&response_body),
445            ))
446        }
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    #[test]
453    fn test_client_initialization() {
454        let _client =
455            crate::providers::voyageai::Client::new("dummy-key").expect("Client::new() failed");
456        let _client_from_builder = crate::providers::voyageai::Client::builder()
457            .api_key("dummy-key")
458            .build()
459            .expect("Client::builder() failed");
460    }
461
462    #[tokio::test]
463    async fn rerank_non_success_preserves_status_and_body() {
464        use crate::client::RerankingClient;
465        use crate::rerank::{RerankError, RerankModel as _};
466        use crate::test_utils::RecordingHttpClient;
467
468        let body = r#"{"error":{"message":"boom"}}"#;
469        let http_client =
470            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
471        let client = super::Client::builder()
472            .api_key("test-key")
473            .http_client(http_client)
474            .build()
475            .expect("build client");
476        let model = client.rerank_model(super::RERANK_2_5);
477
478        let error = model
479            .rerank("query", vec!["doc one".to_string(), "doc two".to_string()])
480            .await
481            .expect_err("rerank should fail with non-success status");
482
483        assert!(matches!(error, RerankError::HttpError(_)));
484        assert_eq!(
485            error.provider_response_status(),
486            Some(http::StatusCode::SERVICE_UNAVAILABLE)
487        );
488        assert_eq!(error.provider_response_body(), Some(body));
489    }
490
491    #[tokio::test]
492    async fn rerank_2xx_error_envelope_preserves_status_and_body() {
493        use crate::client::RerankingClient;
494        use crate::rerank::{RerankError, RerankModel as _};
495        use crate::test_utils::RecordingHttpClient;
496
497        let body = r#"{"message":"boom"}"#;
498        let http_client = RecordingHttpClient::new(body); // 200 OK
499        let client = super::Client::builder()
500            .api_key("test-key")
501            .http_client(http_client)
502            .build()
503            .expect("build client");
504        let model = client.rerank_model(super::RERANK_2_5);
505
506        let error = model
507            .rerank("query", vec!["doc one".to_string(), "doc two".to_string()])
508            .await
509            .expect_err("rerank should fail with provider error envelope");
510
511        match &error {
512            RerankError::ProviderResponse(stored) => {
513                assert_eq!(stored.body, body);
514                assert_eq!(stored.status, Some(http::StatusCode::OK));
515            }
516            other => panic!("expected ProviderResponse, got {other:?}"),
517        }
518    }
519}