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
14const 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 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 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
105pub const VOYAGE_3_LARGE: &str = "voyage-3-large";
111pub const VOYAGE_3_5: &str = "voyage-3.5";
113pub const VOYAGE_3_5_LITE: &str = "voyage.3-5.lite";
115pub const VOYAGE_CODE_3: &str = "voyage-code-3";
117pub const VOYAGE_FINANCE_2: &str = "voyage-finance-2";
119pub const VOYAGE_LAW_2: &str = "voyage-law-2";
121pub 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
277pub const RERANK_2_5: &str = "rerank-2.5";
283pub const RERANK_2_5_LITE: &str = "rerank-2.5-lite";
285pub const RERANK_2: &str = "rerank-2";
287pub const RERANK_2_LITE: &str = "rerank-2-lite";
289pub const RERANK_1: &str = "rerank-1";
291pub 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); 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}