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    fn as_str(&self) -> &'static str {
33        match self {
34            CohereEmbedInputType::SearchQuery => "search_query",
35            CohereEmbedInputType::SearchDocument => "search_document",
36            CohereEmbedInputType::Classification => "classification",
37            CohereEmbedInputType::Clustering => "clustering",
38        }
39    }
40}
41
42/// Cohere embedding configuration.
43#[derive(Debug, Clone)]
44pub struct CohereEmbeddingsConfig {
45    pub api_key: String,
46    pub base_url: String,
47    pub model: String,
48    pub input_type: CohereEmbedInputType,
49}
50
51impl Default for CohereEmbeddingsConfig {
52    fn default() -> Self {
53        Self {
54            api_key: String::new(),
55            base_url: COHERE_EMBED_BASE_URL.to_string(),
56            model: COHERE_EMBED_MODEL.to_string(),
57            input_type: CohereEmbedInputType::SearchQuery,
58        }
59    }
60}
61
62impl CohereEmbeddingsConfig {
63    /// Creates a new config with the given API key.
64    pub fn new(api_key: impl Into<String>) -> Self {
65        Self {
66            api_key: api_key.into(),
67            ..Default::default()
68        }
69    }
70
71    /// Creates config from environment variables.
72    pub fn from_env_result() -> Result<Self, String> {
73        let api_key = std::env::var("COHERE_API_KEY")
74            .map_err(|_| "COHERE_API_KEY environment variable not set".to_string())?;
75        let base_url =
76            std::env::var("COHERE_BASE_URL").unwrap_or_else(|_| COHERE_EMBED_BASE_URL.to_string());
77        let model =
78            std::env::var("COHERE_EMBED_MODEL").unwrap_or_else(|_| COHERE_EMBED_MODEL.to_string());
79        Ok(Self {
80            api_key,
81            base_url,
82            model,
83            ..Default::default()
84        })
85    }
86
87    /// Sets the model name.
88    pub fn with_model(mut self, model: impl Into<String>) -> Self {
89        self.model = model.into();
90        self
91    }
92
93    /// Sets the input type.
94    pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
95        self.input_type = input_type;
96        self
97    }
98}
99
100/// Cohere embedding response.
101#[derive(Debug, Deserialize)]
102struct CohereEmbedResponse {
103    data: Vec<CohereEmbedData>,
104}
105
106#[derive(Debug, Deserialize)]
107struct CohereEmbedData {
108    embedding: Vec<f32>,
109}
110
111/// Cohere embedding provider.
112pub struct CohereEmbeddings {
113    config: CohereEmbeddingsConfig,
114    client: reqwest::Client,
115    dimension: usize,
116}
117
118impl std::fmt::Debug for CohereEmbeddings {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("CohereEmbeddings")
121            .field("model", &self.config.model)
122            .finish()
123    }
124}
125
126impl CohereEmbeddings {
127    /// Creates a new CohereEmbeddings with the given configuration.
128    ///
129    /// 构造时 fail fast(P1-3):API key 为空立即报错。模型维度已知才构造
130    /// (P1-2):Cohere v3.0 系列(english/multilingual)均为 1024 维,
131    /// 未知模型报错而非恒 1024 撒谎。
132    pub fn new(config: CohereEmbeddingsConfig) -> Result<Self, EmbeddingError> {
133        if config.api_key.trim().is_empty() {
134            return Err(EmbeddingError::Config(
135                "COHERE_API_KEY is empty".to_string(),
136            ));
137        }
138        let dimension = Self::dimension_for(&config.model)?;
139        Ok(Self {
140            config,
141            client: reqwest::Client::new(),
142            dimension,
143        })
144    }
145
146    /// 已知模型的维度表;Cohere v3.0 系列均为 1024 维(P1-2)。
147    fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
148        match model {
149            "embed-english-v3.0" | "embed-multilingual-v3.0" => Ok(1024),
150            other => Err(EmbeddingError::Config(format!(
151                "unknown embedding dimension for Cohere model '{other}' \
152                 (supported: 'embed-english-v3.0', 'embed-multilingual-v3.0')"
153            ))),
154        }
155    }
156
157    /// Creates from environment variables.
158    pub fn from_env_result() -> Result<Self, String> {
159        let config = CohereEmbeddingsConfig::from_env_result()?;
160        Self::new(config).map_err(|e| e.to_string())
161    }
162}
163
164#[async_trait]
165impl Embeddings for CohereEmbeddings {
166    async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
167        // P1-1: 补上 Cohere 缺失的空输入检查,与其他 provider 契约一致。
168        if text.trim().is_empty() {
169            return Err(EmbeddingError::EmptyInput);
170        }
171
172        let url = format!("{}/embed", self.config.base_url);
173        let body = json!({
174            "model": self.config.model,
175            "input_type": self.config.input_type.as_str(),
176            "texts": [text],
177            "embedding_types": ["float"],
178        });
179
180        // P2-5: 429/5xx 指数退避重试。
181        let response = crate::retry::post_json_with_retry(
182            &self.client,
183            &url,
184            &self.config.api_key,
185            &body,
186            &crate::retry::DEFAULT_RETRY,
187        )
188        .await
189        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
190
191        let status = response.status();
192        if !status.is_success() {
193            // P1-4: 读失败的错误体也要报错,不能 unwrap_or_default() 吞掉。
194            let error_text = response.text().await.map_err(|e| {
195                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
196            })?;
197            return Err(EmbeddingError::ApiError(format!(
198                "HTTP {}: {}",
199                status, error_text
200            )));
201        }
202
203        let embed_response: CohereEmbedResponse = response
204            .json()
205            .await
206            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
207
208        let mut embedding = embed_response
209            .data
210            .first()
211            .map(|d| d.embedding.clone())
212            .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))?;
213        // P2-8: 统一 L2 归一化,保证单位长度。
214        crate::l2_normalize(&mut embedding);
215        Ok(embedding)
216    }
217
218    async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
219        // P1-1: 空切片不是错误(无事可做),含空/全空白文本才报错——与其他 provider 契约统一。
220        if texts.is_empty() {
221            return Ok(Vec::new());
222        }
223        if texts.iter().any(|t| t.trim().is_empty()) {
224            return Err(EmbeddingError::EmptyInput);
225        }
226
227        let url = format!("{}/embed", self.config.base_url);
228        let body = json!({
229            "model": self.config.model,
230            "input_type": CohereEmbedInputType::SearchDocument.as_str(),
231            "texts": texts,
232            "embedding_types": ["float"],
233        });
234
235        // P2-5: 429/5xx 指数退避重试。
236        let response = crate::retry::post_json_with_retry(
237            &self.client,
238            &url,
239            &self.config.api_key,
240            &body,
241            &crate::retry::DEFAULT_RETRY,
242        )
243        .await
244        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
245
246        let status = response.status();
247        if !status.is_success() {
248            // P1-4: 读失败的错误体也要报错,不能 unwrap_or_default() 吞掉。
249            let error_text = response.text().await.map_err(|e| {
250                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
251            })?;
252            return Err(EmbeddingError::ApiError(format!(
253                "HTTP {}: {}",
254                status, error_text
255            )));
256        }
257
258        let embed_response: CohereEmbedResponse = response
259            .json()
260            .await
261            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
262
263        let mut embeddings: Vec<Vec<f32>> = embed_response
264            .data
265            .into_iter()
266            .map(|d| d.embedding)
267            .collect();
268
269        // P0-1: Cohere 一次请求全部文本,必须校验返回量与请求量一致,
270        // 否则少返回的向量会让下游张冠李戴。
271        if embeddings.len() != texts.len() {
272            return Err(EmbeddingError::BatchMismatch {
273                expected: texts.len(),
274                actual: embeddings.len(),
275            });
276        }
277
278        // P2-8: 逐条统一 L2 归一化,保证单位长度。
279        for v in embeddings.iter_mut() {
280            crate::l2_normalize(v);
281        }
282
283        Ok(embeddings)
284    }
285
286    fn dimension(&self) -> usize {
287        self.dimension
288    }
289
290    fn model_name(&self) -> &str {
291        &self.config.model
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::test_support::{spawn_embeddings_stub, spawn_status_stub};
299    use std::sync::atomic::Ordering;
300    use std::sync::Arc;
301
302    /// P2-5: Cohere 同样接线 429 重试。
303    #[tokio::test]
304    async fn test_embed_query_retries_on_429() {
305        let success_body = r#"{"data":[{"embedding":[0.6,0.8]}]}"#;
306        let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
307        let config = CohereEmbeddingsConfig {
308            api_key: "test-key".into(),
309            base_url,
310            model: COHERE_EMBED_MODEL.into(),
311            input_type: CohereEmbedInputType::SearchQuery,
312        };
313        let embeddings = CohereEmbeddings::new(config).unwrap();
314
315        let v = embeddings
316            .embed_query("hello")
317            .await
318            .expect("429 两次后应重试成功");
319        assert_eq!(v.len(), 2);
320        // P2-8: 返回向量应已归一化。
321        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
322        assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm);
323        assert_eq!(requests.load(Ordering::SeqCst), 3, "1 次初始 + 2 次重试");
324    }
325
326    /// P0-1: Cohere 一次性返回全部文本,少返回必须显式报 `BatchMismatch`,
327    /// 而非静默少向量让下游错位。
328    #[tokio::test]
329    async fn test_embed_documents_truncated_errors() {
330        let base_url = spawn_embeddings_stub(Arc::new(|n| n.saturating_sub(1))).await;
331        let config = CohereEmbeddingsConfig {
332            api_key: "test-key".into(),
333            base_url,
334            model: COHERE_EMBED_MODEL.into(),
335            input_type: CohereEmbedInputType::SearchDocument,
336        };
337        let embeddings = CohereEmbeddings::new(config).unwrap();
338
339        let result = embeddings.embed_documents(&["a", "b"]).await;
340        assert!(
341            matches!(
342                result,
343                Err(EmbeddingError::BatchMismatch {
344                    expected: 2,
345                    actual: 1
346                })
347            ),
348            "少返回应报 BatchMismatch,实际: {:?}",
349            result
350        );
351    }
352
353    #[test]
354    fn test_config_new() {
355        let config = CohereEmbeddingsConfig::new("test-key");
356        assert_eq!(config.api_key, "test-key");
357        assert_eq!(config.model, COHERE_EMBED_MODEL);
358    }
359
360    #[test]
361    fn test_config_builder() {
362        let config = CohereEmbeddingsConfig::new("key")
363            .with_model("embed-multilingual-v3.0")
364            .with_input_type(CohereEmbedInputType::SearchDocument);
365        assert_eq!(config.model, "embed-multilingual-v3.0");
366        assert!(matches!(
367            config.input_type,
368            CohereEmbedInputType::SearchDocument
369        ));
370    }
371
372    #[test]
373    fn test_input_type_str() {
374        assert_eq!(CohereEmbedInputType::SearchQuery.as_str(), "search_query");
375        assert_eq!(
376            CohereEmbedInputType::SearchDocument.as_str(),
377            "search_document"
378        );
379        assert_eq!(
380            CohereEmbedInputType::Classification.as_str(),
381            "classification"
382        );
383        assert_eq!(CohereEmbedInputType::Clustering.as_str(), "clustering");
384    }
385
386    #[test]
387    fn test_embeddings_new() {
388        let config = CohereEmbeddingsConfig::new("key");
389        let embeddings = CohereEmbeddings::new(config).unwrap();
390        assert_eq!(embeddings.model_name(), COHERE_EMBED_MODEL);
391        assert_eq!(embeddings.dimension(), 1024);
392    }
393
394    /// P1-3: API key 为空 → 构造期 fail fast 报 `Config`,而非拖到发请求才 401。
395    #[test]
396    fn test_new_rejects_empty_api_key() {
397        let config = CohereEmbeddingsConfig {
398            api_key: String::new(),
399            base_url: COHERE_EMBED_BASE_URL.into(),
400            model: COHERE_EMBED_MODEL.into(),
401            input_type: CohereEmbedInputType::SearchDocument,
402        };
403        let err = CohereEmbeddings::new(config).unwrap_err();
404        assert!(matches!(err, EmbeddingError::Config(_)));
405    }
406
407    /// P1-2: 未知模型 → 构造期报错,不得恒 1024 撒谎。
408    #[test]
409    fn test_new_rejects_unknown_model() {
410        let config = CohereEmbeddingsConfig::new("key").with_model("some-unknown-model");
411        let err = CohereEmbeddings::new(config).unwrap_err();
412        assert!(matches!(err, EmbeddingError::Config(_)));
413    }
414
415    /// P1-1: 空文本 / 全空白文本 → `Err(EmptyInput)`;空切片 → `Ok(vec![])`。
416    #[tokio::test]
417    async fn test_empty_input_contract() {
418        let embeddings = CohereEmbeddings::new(CohereEmbeddingsConfig::new("key")).unwrap();
419        assert!(matches!(
420            embeddings.embed_query("").await,
421            Err(EmbeddingError::EmptyInput)
422        ));
423        assert!(matches!(
424            embeddings.embed_query("   ").await,
425            Err(EmbeddingError::EmptyInput)
426        ));
427        assert_eq!(
428            embeddings.embed_documents(&[]).await.unwrap(),
429            Vec::<Vec<f32>>::new()
430        );
431        assert!(matches!(
432            embeddings.embed_documents(&["ok", " "]).await,
433            Err(EmbeddingError::EmptyInput)
434        ));
435    }
436}