Skip to main content

lc_embeddings/
cohere.rs

1// lc-embeddings/src/cohere.rs
2//! Cohere Embeddings — embed-english-v3.0 / embed-multilingual-v3.0.
3//!
4//! Uses Cohere's v2/embed endpoint for generating text embeddings.
5
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::json;
9
10use crate::{EmbeddingError, Embeddings};
11
12/// Default Cohere embedding model.
13pub const COHERE_EMBED_MODEL: &str = "embed-english-v3.0";
14
15/// Cohere API base URL.
16pub const COHERE_EMBED_BASE_URL: &str = "https://api.cohere.com/v2";
17
18/// Cohere embedding input type.
19#[derive(Debug, Clone, Copy)]
20pub enum CohereEmbedInputType {
21    /// Search query embedding.
22    SearchQuery,
23    /// Search document embedding.
24    SearchDocument,
25    /// Classification embedding.
26    Classification,
27    /// Clustering embedding.
28    Clustering,
29}
30
31impl CohereEmbedInputType {
32    /// Wire token for the v2/embed `input_type` field.
33    pub(crate) fn as_str(&self) -> &'static str {
34        match self {
35            CohereEmbedInputType::SearchQuery => "search_query",
36            CohereEmbedInputType::SearchDocument => "search_document",
37            CohereEmbedInputType::Classification => "classification",
38            CohereEmbedInputType::Clustering => "clustering",
39        }
40    }
41}
42
43/// Cohere embedding configuration.
44#[derive(Debug, Clone)]
45pub struct CohereEmbeddingsConfig {
46    /// Cohere API key.
47    pub api_key: String,
48    /// Base URL for the Cohere embeddings API.
49    pub base_url: String,
50    /// Embedding model name.
51    pub model: String,
52    /// Input type for the embedding request.
53    pub input_type: CohereEmbedInputType,
54}
55
56impl Default for CohereEmbeddingsConfig {
57    fn default() -> Self {
58        Self {
59            api_key: String::new(),
60            base_url: COHERE_EMBED_BASE_URL.to_string(),
61            model: COHERE_EMBED_MODEL.to_string(),
62            input_type: CohereEmbedInputType::SearchQuery,
63        }
64    }
65}
66
67impl CohereEmbeddingsConfig {
68    /// Creates a new config with the given API key.
69    pub fn new(api_key: impl Into<String>) -> Self {
70        Self {
71            api_key: api_key.into(),
72            ..Default::default()
73        }
74    }
75
76    /// Creates config from environment variables.
77    pub fn from_env_result() -> Result<Self, EmbeddingError> {
78        let api_key = std::env::var("COHERE_API_KEY").map_err(|_| {
79            EmbeddingError::Config("COHERE_API_KEY environment variable not set".to_string())
80        })?;
81        let base_url =
82            std::env::var("COHERE_BASE_URL").unwrap_or_else(|_| COHERE_EMBED_BASE_URL.to_string());
83        let model =
84            std::env::var("COHERE_EMBED_MODEL").unwrap_or_else(|_| COHERE_EMBED_MODEL.to_string());
85        Ok(Self {
86            api_key,
87            base_url,
88            model,
89            ..Default::default()
90        })
91    }
92
93    /// Sets the model name.
94    pub fn with_model(mut self, model: impl Into<String>) -> Self {
95        self.model = model.into();
96        self
97    }
98
99    /// Sets the input type.
100    pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
101        self.input_type = input_type;
102        self
103    }
104}
105
106/// Cohere embedding response.
107#[derive(Debug, Deserialize)]
108struct CohereEmbedResponse {
109    data: Vec<CohereEmbedData>,
110}
111
112#[derive(Debug, Deserialize)]
113struct CohereEmbedData {
114    embedding: Vec<f32>,
115}
116
117/// Cohere embedding provider.
118pub struct CohereEmbeddings {
119    config: CohereEmbeddingsConfig,
120    client: reqwest::Client,
121    dimension: usize,
122}
123
124impl std::fmt::Debug for CohereEmbeddings {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("CohereEmbeddings")
127            .field("model", &self.config.model)
128            .finish()
129    }
130}
131
132impl CohereEmbeddings {
133    /// Creates a new CohereEmbeddings with the given configuration.
134    ///
135    /// Fails fast at construction (P1-3): an empty API key errors immediately. Constructs only
136    /// when the model dimension is known (P1-2): the Cohere v3.0 family (english/multilingual)
137    /// is always 1024-dimensional; unknown models error rather than lying with a fixed 1024.
138    pub fn new(config: CohereEmbeddingsConfig) -> Result<Self, EmbeddingError> {
139        if config.api_key.trim().is_empty() {
140            return Err(EmbeddingError::Config(
141                "COHERE_API_KEY is empty".to_string(),
142            ));
143        }
144        let dimension = Self::dimension_for(&config.model)?;
145        Ok(Self {
146            config,
147            client: reqwest::Client::new(),
148            dimension,
149        })
150    }
151
152    /// Dimension table for known models; the Cohere v3.0 family is always 1024 (P1-2).
153    fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
154        match model {
155            "embed-english-v3.0" | "embed-multilingual-v3.0" => Ok(1024),
156            other => Err(EmbeddingError::Config(format!(
157                "unknown embedding dimension for Cohere model '{other}' \
158                 (supported: 'embed-english-v3.0', 'embed-multilingual-v3.0')"
159            ))),
160        }
161    }
162
163    /// Creates from environment variables.
164    pub fn from_env_result() -> Result<Self, EmbeddingError> {
165        let config = CohereEmbeddingsConfig::from_env_result()?;
166        Self::new(config)
167    }
168}
169
170#[async_trait]
171impl Embeddings for CohereEmbeddings {
172    async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
173        // P1-1: add the empty-input check Cohere lacks, consistent with other providers' contract.
174        if text.trim().is_empty() {
175            return Err(EmbeddingError::EmptyInput);
176        }
177
178        let url = format!("{}/embed", self.config.base_url);
179        let body = json!({
180            "model": self.config.model,
181            "input_type": self.config.input_type.as_str(),
182            "texts": [text],
183            "embedding_types": ["float"],
184        });
185
186        // P2-5: exponential backoff retry on 429/5xx.
187        let response = crate::retry::post_json_with_retry(
188            &self.client,
189            &url,
190            &self.config.api_key,
191            &body,
192            &crate::retry::DEFAULT_RETRY,
193        )
194        .await
195        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
196
197        let status = response.status();
198        if !status.is_success() {
199            // P1-4: the error body must also error if reading fails; do not swallow it with unwrap_or_default().
200            let error_text = response.text().await.map_err(|e| {
201                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
202            })?;
203            return Err(EmbeddingError::ApiError(format!(
204                "HTTP {}: {}",
205                status, error_text
206            )));
207        }
208
209        let embed_response: CohereEmbedResponse = response
210            .json()
211            .await
212            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
213
214        let mut embedding = embed_response
215            .data
216            .first()
217            .map(|d| d.embedding.clone())
218            .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))?;
219        // P2-8: uniform L2 normalization, guaranteeing unit length.
220        crate::l2_normalize(&mut embedding);
221        Ok(embedding)
222    }
223
224    async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
225        // P1-1: an empty slice is not an error (nothing to do); only empty/all-whitespace texts error — uniform with other providers.
226        if texts.is_empty() {
227            return Ok(Vec::new());
228        }
229        if texts.iter().any(|t| t.trim().is_empty()) {
230            return Err(EmbeddingError::EmptyInput);
231        }
232
233        let url = format!("{}/embed", self.config.base_url);
234        let body = json!({
235            "model": self.config.model,
236            "input_type": CohereEmbedInputType::SearchDocument.as_str(),
237            "texts": texts,
238            "embedding_types": ["float"],
239        });
240
241        // P2-5: exponential backoff retry on 429/5xx.
242        let response = crate::retry::post_json_with_retry(
243            &self.client,
244            &url,
245            &self.config.api_key,
246            &body,
247            &crate::retry::DEFAULT_RETRY,
248        )
249        .await
250        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
251
252        let status = response.status();
253        if !status.is_success() {
254            // P1-4: the error body must also error if reading fails; do not swallow it with unwrap_or_default().
255            let error_text = response.text().await.map_err(|e| {
256                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
257            })?;
258            return Err(EmbeddingError::ApiError(format!(
259                "HTTP {}: {}",
260                status, error_text
261            )));
262        }
263
264        let embed_response: CohereEmbedResponse = response
265            .json()
266            .await
267            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
268
269        let mut embeddings: Vec<Vec<f32>> = embed_response
270            .data
271            .into_iter()
272            .map(|d| d.embedding)
273            .collect();
274
275        // P0-1: Cohere sends all texts in one request, so the returned count must match the
276        // requested count, otherwise missing vectors would silently misalign downstream.
277        if embeddings.len() != texts.len() {
278            return Err(EmbeddingError::BatchMismatch {
279                expected: texts.len(),
280                actual: embeddings.len(),
281            });
282        }
283
284        // P2-8: per-item uniform L2 normalization, guaranteeing unit length.
285        for v in embeddings.iter_mut() {
286            crate::l2_normalize(v);
287        }
288
289        Ok(embeddings)
290    }
291
292    fn dimension(&self) -> usize {
293        self.dimension
294    }
295
296    fn model_name(&self) -> &str {
297        &self.config.model
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::test_support::{spawn_embeddings_stub, spawn_status_stub};
305    use std::sync::atomic::Ordering;
306    use std::sync::Arc;
307
308    /// P2-5: Cohere also wires in 429 retry.
309    #[tokio::test]
310    async fn test_embed_query_retries_on_429() {
311        let success_body = r#"{"data":[{"embedding":[0.6,0.8]}]}"#;
312        let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
313        let config = CohereEmbeddingsConfig {
314            api_key: "test-key".into(),
315            base_url,
316            model: COHERE_EMBED_MODEL.into(),
317            input_type: CohereEmbedInputType::SearchQuery,
318        };
319        let embeddings = CohereEmbeddings::new(config).unwrap();
320
321        let v = embeddings
322            .embed_query("hello")
323            .await
324            .expect("should retry successfully after two 429s");
325        assert_eq!(v.len(), 2);
326        // P2-8: the returned vector should be normalized.
327        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
328        assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm);
329        assert_eq!(requests.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
330    }
331
332    /// P0-1: Cohere returns all texts at once; a short return must explicitly report
333    /// `BatchMismatch` rather than silently giving downstream fewer vectors.
334    #[tokio::test]
335    async fn test_embed_documents_truncated_errors() {
336        let base_url = spawn_embeddings_stub(Arc::new(|n| n.saturating_sub(1))).await;
337        let config = CohereEmbeddingsConfig {
338            api_key: "test-key".into(),
339            base_url,
340            model: COHERE_EMBED_MODEL.into(),
341            input_type: CohereEmbedInputType::SearchDocument,
342        };
343        let embeddings = CohereEmbeddings::new(config).unwrap();
344
345        let result = embeddings.embed_documents(&["a", "b"]).await;
346        assert!(
347            matches!(
348                result,
349                Err(EmbeddingError::BatchMismatch {
350                    expected: 2,
351                    actual: 1
352                })
353            ),
354            "truncated response should report BatchMismatch, got: {:?}",
355            result
356        );
357    }
358
359    #[test]
360    fn test_config_new() {
361        let config = CohereEmbeddingsConfig::new("test-key");
362        assert_eq!(config.api_key, "test-key");
363        assert_eq!(config.model, COHERE_EMBED_MODEL);
364    }
365
366    #[test]
367    fn test_config_builder() {
368        let config = CohereEmbeddingsConfig::new("key")
369            .with_model("embed-multilingual-v3.0")
370            .with_input_type(CohereEmbedInputType::SearchDocument);
371        assert_eq!(config.model, "embed-multilingual-v3.0");
372        assert!(matches!(
373            config.input_type,
374            CohereEmbedInputType::SearchDocument
375        ));
376    }
377
378    #[test]
379    fn test_input_type_str() {
380        assert_eq!(CohereEmbedInputType::SearchQuery.as_str(), "search_query");
381        assert_eq!(
382            CohereEmbedInputType::SearchDocument.as_str(),
383            "search_document"
384        );
385        assert_eq!(
386            CohereEmbedInputType::Classification.as_str(),
387            "classification"
388        );
389        assert_eq!(CohereEmbedInputType::Clustering.as_str(), "clustering");
390    }
391
392    #[test]
393    fn test_embeddings_new() {
394        let config = CohereEmbeddingsConfig::new("key");
395        let embeddings = CohereEmbeddings::new(config).unwrap();
396        assert_eq!(embeddings.model_name(), COHERE_EMBED_MODEL);
397        assert_eq!(embeddings.dimension(), 1024);
398    }
399
400    /// P1-3: an empty API key → `Config` error at construction (fail fast), not a delayed 401.
401    #[test]
402    fn test_new_rejects_empty_api_key() {
403        let config = CohereEmbeddingsConfig {
404            api_key: String::new(),
405            base_url: COHERE_EMBED_BASE_URL.into(),
406            model: COHERE_EMBED_MODEL.into(),
407            input_type: CohereEmbedInputType::SearchDocument,
408        };
409        let err = CohereEmbeddings::new(config).unwrap_err();
410        assert!(matches!(err, EmbeddingError::Config(_)));
411    }
412
413    /// P1-2: unknown model → construction-time error, never lying with a fixed 1024.
414    #[test]
415    fn test_new_rejects_unknown_model() {
416        let config = CohereEmbeddingsConfig::new("key").with_model("some-unknown-model");
417        let err = CohereEmbeddings::new(config).unwrap_err();
418        assert!(matches!(err, EmbeddingError::Config(_)));
419    }
420
421    /// P1-1: empty / all-whitespace text → `Err(EmptyInput)`; an empty slice → `Ok(vec![])`.
422    #[tokio::test]
423    async fn test_empty_input_contract() {
424        let embeddings = CohereEmbeddings::new(CohereEmbeddingsConfig::new("key")).unwrap();
425        assert!(matches!(
426            embeddings.embed_query("").await,
427            Err(EmbeddingError::EmptyInput)
428        ));
429        assert!(matches!(
430            embeddings.embed_query("   ").await,
431            Err(EmbeddingError::EmptyInput)
432        ));
433        assert_eq!(
434            embeddings.embed_documents(&[]).await.unwrap(),
435            Vec::<Vec<f32>>::new()
436        );
437        assert!(matches!(
438            embeddings.embed_documents(&["ok", " "]).await,
439            Err(EmbeddingError::EmptyInput)
440        ));
441    }
442}