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 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#[derive(Debug, Clone)]
45pub struct CohereEmbeddingsConfig {
46 pub api_key: String,
48 pub base_url: String,
50 pub model: String,
52 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 pub fn new(api_key: impl Into<String>) -> Self {
70 Self {
71 api_key: api_key.into(),
72 ..Default::default()
73 }
74 }
75
76 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 pub fn with_model(mut self, model: impl Into<String>) -> Self {
95 self.model = model.into();
96 self
97 }
98
99 pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
101 self.input_type = input_type;
102 self
103 }
104}
105
106#[derive(Debug, Deserialize)]
108struct CohereEmbedResponse {
109 data: Vec<CohereEmbedData>,
110}
111
112#[derive(Debug, Deserialize)]
113struct CohereEmbedData {
114 embedding: Vec<f32>,
115}
116
117pub 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 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 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 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 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 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 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 crate::l2_normalize(&mut embedding);
221 Ok(embedding)
222 }
223
224 async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
225 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 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 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 if embeddings.len() != texts.len() {
278 return Err(EmbeddingError::BatchMismatch {
279 expected: texts.len(),
280 actual: embeddings.len(),
281 });
282 }
283
284 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 #[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 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 #[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 #[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 #[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 #[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}