1use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::json;
9
10use crate::{EmbeddingError, Embeddings};
11
12pub const COHERE_EMBED_MODEL: &str = "embed-english-v3.0";
14
15pub const COHERE_EMBED_BASE_URL: &str = "https://api.cohere.com/v2";
17
18#[derive(Debug, Clone, Copy)]
20pub enum CohereEmbedInputType {
21 SearchQuery,
23 SearchDocument,
25 Classification,
27 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#[derive(Debug, Clone)]
44pub struct CohereEmbeddingsConfig {
45 pub api_key: String,
47 pub base_url: String,
49 pub model: String,
51 pub input_type: CohereEmbedInputType,
53}
54
55impl Default for CohereEmbeddingsConfig {
56 fn default() -> Self {
57 Self {
58 api_key: String::new(),
59 base_url: COHERE_EMBED_BASE_URL.to_string(),
60 model: COHERE_EMBED_MODEL.to_string(),
61 input_type: CohereEmbedInputType::SearchQuery,
62 }
63 }
64}
65
66impl CohereEmbeddingsConfig {
67 pub fn new(api_key: impl Into<String>) -> Self {
69 Self {
70 api_key: api_key.into(),
71 ..Default::default()
72 }
73 }
74
75 pub fn from_env_result() -> Result<Self, EmbeddingError> {
77 let api_key = std::env::var("COHERE_API_KEY").map_err(|_| {
78 EmbeddingError::Config("COHERE_API_KEY environment variable not set".to_string())
79 })?;
80 let base_url =
81 std::env::var("COHERE_BASE_URL").unwrap_or_else(|_| COHERE_EMBED_BASE_URL.to_string());
82 let model =
83 std::env::var("COHERE_EMBED_MODEL").unwrap_or_else(|_| COHERE_EMBED_MODEL.to_string());
84 Ok(Self {
85 api_key,
86 base_url,
87 model,
88 ..Default::default()
89 })
90 }
91
92 pub fn with_model(mut self, model: impl Into<String>) -> Self {
94 self.model = model.into();
95 self
96 }
97
98 pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
100 self.input_type = input_type;
101 self
102 }
103}
104
105#[derive(Debug, Deserialize)]
107struct CohereEmbedResponse {
108 data: Vec<CohereEmbedData>,
109}
110
111#[derive(Debug, Deserialize)]
112struct CohereEmbedData {
113 embedding: Vec<f32>,
114}
115
116pub struct CohereEmbeddings {
118 config: CohereEmbeddingsConfig,
119 client: reqwest::Client,
120 dimension: usize,
121}
122
123impl std::fmt::Debug for CohereEmbeddings {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("CohereEmbeddings")
126 .field("model", &self.config.model)
127 .finish()
128 }
129}
130
131impl CohereEmbeddings {
132 pub fn new(config: CohereEmbeddingsConfig) -> Result<Self, EmbeddingError> {
138 if config.api_key.trim().is_empty() {
139 return Err(EmbeddingError::Config(
140 "COHERE_API_KEY is empty".to_string(),
141 ));
142 }
143 let dimension = Self::dimension_for(&config.model)?;
144 Ok(Self {
145 config,
146 client: reqwest::Client::new(),
147 dimension,
148 })
149 }
150
151 fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
153 match model {
154 "embed-english-v3.0" | "embed-multilingual-v3.0" => Ok(1024),
155 other => Err(EmbeddingError::Config(format!(
156 "unknown embedding dimension for Cohere model '{other}' \
157 (supported: 'embed-english-v3.0', 'embed-multilingual-v3.0')"
158 ))),
159 }
160 }
161
162 pub fn from_env_result() -> Result<Self, EmbeddingError> {
164 let config = CohereEmbeddingsConfig::from_env_result()?;
165 Self::new(config)
166 }
167}
168
169#[async_trait]
170impl Embeddings for CohereEmbeddings {
171 async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
172 if text.trim().is_empty() {
174 return Err(EmbeddingError::EmptyInput);
175 }
176
177 let url = format!("{}/embed", self.config.base_url);
178 let body = json!({
179 "model": self.config.model,
180 "input_type": self.config.input_type.as_str(),
181 "texts": [text],
182 "embedding_types": ["float"],
183 });
184
185 let response = crate::retry::post_json_with_retry(
187 &self.client,
188 &url,
189 &self.config.api_key,
190 &body,
191 &crate::retry::DEFAULT_RETRY,
192 )
193 .await
194 .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
195
196 let status = response.status();
197 if !status.is_success() {
198 let error_text = response.text().await.map_err(|e| {
200 EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
201 })?;
202 return Err(EmbeddingError::ApiError(format!(
203 "HTTP {}: {}",
204 status, error_text
205 )));
206 }
207
208 let embed_response: CohereEmbedResponse = response
209 .json()
210 .await
211 .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
212
213 let mut embedding = embed_response
214 .data
215 .first()
216 .map(|d| d.embedding.clone())
217 .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))?;
218 crate::l2_normalize(&mut embedding);
220 Ok(embedding)
221 }
222
223 async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
224 if texts.is_empty() {
226 return Ok(Vec::new());
227 }
228 if texts.iter().any(|t| t.trim().is_empty()) {
229 return Err(EmbeddingError::EmptyInput);
230 }
231
232 let url = format!("{}/embed", self.config.base_url);
233 let body = json!({
234 "model": self.config.model,
235 "input_type": CohereEmbedInputType::SearchDocument.as_str(),
236 "texts": texts,
237 "embedding_types": ["float"],
238 });
239
240 let response = crate::retry::post_json_with_retry(
242 &self.client,
243 &url,
244 &self.config.api_key,
245 &body,
246 &crate::retry::DEFAULT_RETRY,
247 )
248 .await
249 .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
250
251 let status = response.status();
252 if !status.is_success() {
253 let error_text = response.text().await.map_err(|e| {
255 EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
256 })?;
257 return Err(EmbeddingError::ApiError(format!(
258 "HTTP {}: {}",
259 status, error_text
260 )));
261 }
262
263 let embed_response: CohereEmbedResponse = response
264 .json()
265 .await
266 .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
267
268 let mut embeddings: Vec<Vec<f32>> = embed_response
269 .data
270 .into_iter()
271 .map(|d| d.embedding)
272 .collect();
273
274 if embeddings.len() != texts.len() {
277 return Err(EmbeddingError::BatchMismatch {
278 expected: texts.len(),
279 actual: embeddings.len(),
280 });
281 }
282
283 for v in embeddings.iter_mut() {
285 crate::l2_normalize(v);
286 }
287
288 Ok(embeddings)
289 }
290
291 fn dimension(&self) -> usize {
292 self.dimension
293 }
294
295 fn model_name(&self) -> &str {
296 &self.config.model
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::test_support::{spawn_embeddings_stub, spawn_status_stub};
304 use std::sync::atomic::Ordering;
305 use std::sync::Arc;
306
307 #[tokio::test]
309 async fn test_embed_query_retries_on_429() {
310 let success_body = r#"{"data":[{"embedding":[0.6,0.8]}]}"#;
311 let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
312 let config = CohereEmbeddingsConfig {
313 api_key: "test-key".into(),
314 base_url,
315 model: COHERE_EMBED_MODEL.into(),
316 input_type: CohereEmbedInputType::SearchQuery,
317 };
318 let embeddings = CohereEmbeddings::new(config).unwrap();
319
320 let v = embeddings
321 .embed_query("hello")
322 .await
323 .expect("should retry successfully after two 429s");
324 assert_eq!(v.len(), 2);
325 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
327 assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm);
328 assert_eq!(requests.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
329 }
330
331 #[tokio::test]
334 async fn test_embed_documents_truncated_errors() {
335 let base_url = spawn_embeddings_stub(Arc::new(|n| n.saturating_sub(1))).await;
336 let config = CohereEmbeddingsConfig {
337 api_key: "test-key".into(),
338 base_url,
339 model: COHERE_EMBED_MODEL.into(),
340 input_type: CohereEmbedInputType::SearchDocument,
341 };
342 let embeddings = CohereEmbeddings::new(config).unwrap();
343
344 let result = embeddings.embed_documents(&["a", "b"]).await;
345 assert!(
346 matches!(
347 result,
348 Err(EmbeddingError::BatchMismatch {
349 expected: 2,
350 actual: 1
351 })
352 ),
353 "truncated response should report BatchMismatch, got: {:?}",
354 result
355 );
356 }
357
358 #[test]
359 fn test_config_new() {
360 let config = CohereEmbeddingsConfig::new("test-key");
361 assert_eq!(config.api_key, "test-key");
362 assert_eq!(config.model, COHERE_EMBED_MODEL);
363 }
364
365 #[test]
366 fn test_config_builder() {
367 let config = CohereEmbeddingsConfig::new("key")
368 .with_model("embed-multilingual-v3.0")
369 .with_input_type(CohereEmbedInputType::SearchDocument);
370 assert_eq!(config.model, "embed-multilingual-v3.0");
371 assert!(matches!(
372 config.input_type,
373 CohereEmbedInputType::SearchDocument
374 ));
375 }
376
377 #[test]
378 fn test_input_type_str() {
379 assert_eq!(CohereEmbedInputType::SearchQuery.as_str(), "search_query");
380 assert_eq!(
381 CohereEmbedInputType::SearchDocument.as_str(),
382 "search_document"
383 );
384 assert_eq!(
385 CohereEmbedInputType::Classification.as_str(),
386 "classification"
387 );
388 assert_eq!(CohereEmbedInputType::Clustering.as_str(), "clustering");
389 }
390
391 #[test]
392 fn test_embeddings_new() {
393 let config = CohereEmbeddingsConfig::new("key");
394 let embeddings = CohereEmbeddings::new(config).unwrap();
395 assert_eq!(embeddings.model_name(), COHERE_EMBED_MODEL);
396 assert_eq!(embeddings.dimension(), 1024);
397 }
398
399 #[test]
401 fn test_new_rejects_empty_api_key() {
402 let config = CohereEmbeddingsConfig {
403 api_key: String::new(),
404 base_url: COHERE_EMBED_BASE_URL.into(),
405 model: COHERE_EMBED_MODEL.into(),
406 input_type: CohereEmbedInputType::SearchDocument,
407 };
408 let err = CohereEmbeddings::new(config).unwrap_err();
409 assert!(matches!(err, EmbeddingError::Config(_)));
410 }
411
412 #[test]
414 fn test_new_rejects_unknown_model() {
415 let config = CohereEmbeddingsConfig::new("key").with_model("some-unknown-model");
416 let err = CohereEmbeddings::new(config).unwrap_err();
417 assert!(matches!(err, EmbeddingError::Config(_)));
418 }
419
420 #[tokio::test]
422 async fn test_empty_input_contract() {
423 let embeddings = CohereEmbeddings::new(CohereEmbeddingsConfig::new("key")).unwrap();
424 assert!(matches!(
425 embeddings.embed_query("").await,
426 Err(EmbeddingError::EmptyInput)
427 ));
428 assert!(matches!(
429 embeddings.embed_query(" ").await,
430 Err(EmbeddingError::EmptyInput)
431 ));
432 assert_eq!(
433 embeddings.embed_documents(&[]).await.unwrap(),
434 Vec::<Vec<f32>>::new()
435 );
436 assert!(matches!(
437 embeddings.embed_documents(&["ok", " "]).await,
438 Err(EmbeddingError::EmptyInput)
439 ));
440 }
441}