1use async_trait::async_trait;
21use serde::Deserialize;
22use serde_json::json;
23
24use crate::retry::{post_json_with_retry, DEFAULT_RETRY};
25use crate::vision::{ImageInput, VisionEmbeddings};
26use crate::{l2_normalize, CohereEmbedInputType, EmbeddingError};
27
28pub const COHERE_VISION_EMBED_MODEL: &str = "embed-v4.0";
30
31pub const COHERE_VISION_DIMENSION: usize = 1536;
33
34#[derive(Debug, Clone)]
36pub struct CohereVisionEmbeddingsConfig {
37 pub api_key: String,
39 pub base_url: String,
41 pub model: String,
43 pub text_input_type: CohereEmbedInputType,
46}
47
48impl Default for CohereVisionEmbeddingsConfig {
49 fn default() -> Self {
50 Self {
51 api_key: String::new(),
52 base_url: crate::cohere::COHERE_EMBED_BASE_URL.to_string(),
53 model: COHERE_VISION_EMBED_MODEL.to_string(),
54 text_input_type: CohereEmbedInputType::SearchQuery,
55 }
56 }
57}
58
59impl CohereVisionEmbeddingsConfig {
60 pub fn new(api_key: impl Into<String>) -> Self {
63 Self {
64 api_key: api_key.into(),
65 ..Default::default()
66 }
67 }
68
69 pub fn from_env_result() -> Result<Self, EmbeddingError> {
72 let api_key = std::env::var("COHERE_API_KEY").map_err(|_| {
73 EmbeddingError::Config("COHERE_API_KEY environment variable not set".to_string())
74 })?;
75 let base_url = std::env::var("COHERE_BASE_URL")
76 .unwrap_or_else(|_| crate::cohere::COHERE_EMBED_BASE_URL.to_string());
77 let model = std::env::var("COHERE_VISION_EMBED_MODEL")
78 .unwrap_or_else(|_| COHERE_VISION_EMBED_MODEL.to_string());
79 Ok(Self {
80 api_key,
81 base_url,
82 model,
83 ..Default::default()
84 })
85 }
86
87 pub fn with_model(mut self, model: impl Into<String>) -> Self {
89 self.model = model.into();
90 self
91 }
92
93 pub fn with_text_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
95 self.text_input_type = input_type;
96 self
97 }
98}
99
100pub struct CohereVisionEmbeddings {
102 config: CohereVisionEmbeddingsConfig,
103 client: reqwest::Client,
104 dimension: usize,
105}
106
107impl std::fmt::Debug for CohereVisionEmbeddings {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("CohereVisionEmbeddings")
110 .field("model", &self.config.model)
111 .finish()
112 }
113}
114
115#[derive(Debug, Deserialize)]
117struct CohereV2EmbedResponse {
118 embeddings: CohereV2FloatEmbeddings,
119}
120
121#[derive(Debug, Deserialize)]
122struct CohereV2FloatEmbeddings {
123 float: Vec<Vec<f32>>,
124}
125
126impl CohereVisionEmbeddings {
127 pub fn new(config: CohereVisionEmbeddingsConfig) -> Result<Self, EmbeddingError> {
130 if config.api_key.trim().is_empty() {
131 return Err(EmbeddingError::Config(
132 "COHERE_API_KEY is empty".to_string(),
133 ));
134 }
135 let dimension = Self::dimension_for(&config.model)?;
136 Ok(Self {
137 config,
138 client: reqwest::Client::new(),
139 dimension,
140 })
141 }
142
143 pub fn from_env_result() -> Result<Self, EmbeddingError> {
145 Self::new(CohereVisionEmbeddingsConfig::from_env_result()?)
146 }
147
148 fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
149 match model {
150 "embed-v4.0" => Ok(COHERE_VISION_DIMENSION),
152 other => Err(EmbeddingError::Config(format!(
153 "unknown embedding dimension for Cohere vision model '{other}' \
154 (supported: 'embed-v4.0')"
155 ))),
156 }
157 }
158
159 fn image_format(mime: &str) -> Result<&'static str, EmbeddingError> {
161 match mime {
162 "image/png" => Ok("png"),
163 "image/jpeg" | "image/jpg" => Ok("jpeg"),
164 "image/webp" => Ok("webp"),
165 "image/gif" => Ok("gif"),
166 other => Err(EmbeddingError::Config(format!(
167 "Cohere vision embeddings support png/jpeg/webp/gif images, got {other}"
168 ))),
169 }
170 }
171
172 fn endpoint(&self) -> String {
173 format!("{}/embed", self.config.base_url.trim_end_matches('/'))
174 }
175
176 async fn post_float_embeddings(
180 &self,
181 body: &serde_json::Value,
182 ) -> Result<Vec<Vec<f32>>, EmbeddingError> {
183 let response = post_json_with_retry(
184 &self.client,
185 &self.endpoint(),
186 &self.config.api_key,
187 body,
188 &DEFAULT_RETRY,
189 )
190 .await
191 .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
192
193 let status = response.status();
194 if !status.is_success() {
195 let error_text = response.text().await.map_err(|e| {
196 EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
197 })?;
198 return Err(EmbeddingError::ApiError(format!(
199 "HTTP {}: {}",
200 status, error_text
201 )));
202 }
203
204 let parsed: CohereV2EmbedResponse = response
205 .json()
206 .await
207 .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
208 Ok(parsed.embeddings.float)
209 }
210}
211
212#[async_trait]
213impl VisionEmbeddings for CohereVisionEmbeddings {
214 async fn embed_image(&self, image: &ImageInput) -> Result<Vec<f32>, EmbeddingError> {
215 let mut batch = self.embed_images(std::slice::from_ref(image)).await?;
216 batch
217 .pop()
218 .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))
219 }
220
221 async fn embed_images(&self, images: &[ImageInput]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
222 if images.is_empty() {
223 return Ok(Vec::new());
224 }
225
226 let mut payload = Vec::with_capacity(images.len());
228 for image in images {
229 let (bytes, mime) = image.inline_parts()?;
230 let format = Self::image_format(&mime)?;
231 payload.push(json!({
232 "image_bytes": {"bytes": bytes},
233 "format": format,
234 }));
235 }
236
237 let body = json!({
238 "model": self.config.model,
239 "input_type": "image",
240 "images": payload,
241 "embedding_types": ["float"],
242 });
243
244 let mut embeddings = self.post_float_embeddings(&body).await?;
245 if embeddings.len() != images.len() {
246 return Err(EmbeddingError::BatchMismatch {
247 expected: images.len(),
248 actual: embeddings.len(),
249 });
250 }
251 for vector in embeddings.iter_mut() {
252 l2_normalize(vector);
253 }
254 Ok(embeddings)
255 }
256
257 async fn embed_text(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
258 if text.trim().is_empty() {
259 return Err(EmbeddingError::EmptyInput);
260 }
261
262 let body = json!({
263 "model": self.config.model,
264 "input_type": self.config.text_input_type.as_str(),
265 "texts": [text],
266 "embedding_types": ["float"],
267 });
268
269 let mut embeddings = self.post_float_embeddings(&body).await?;
270 let mut embedding = embeddings
271 .drain(..)
272 .next()
273 .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))?;
274 l2_normalize(&mut embedding);
275 Ok(embedding)
276 }
277
278 fn dimension(&self) -> usize {
279 self.dimension
280 }
281
282 fn model_name(&self) -> &str {
283 &self.config.model
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::test_support::{spawn_json_recording_stub, spawn_status_stub};
291 use std::sync::atomic::Ordering;
292
293 fn config_with(base_url: String) -> CohereVisionEmbeddingsConfig {
294 CohereVisionEmbeddingsConfig {
295 api_key: "test-key".into(),
296 base_url,
297 model: COHERE_VISION_EMBED_MODEL.into(),
298 text_input_type: CohereEmbedInputType::SearchQuery,
299 }
300 }
301
302 #[test]
303 fn construction_validates_key_and_model() {
304 let mut bad_key = config_with("http://127.0.0.1:1".into());
305 bad_key.api_key = String::new();
306 assert!(matches!(
307 CohereVisionEmbeddings::new(bad_key),
308 Err(EmbeddingError::Config(_))
309 ));
310
311 let mut bad_model = config_with("http://127.0.0.1:1".into());
312 bad_model.model = "unknown-vision".into();
313 assert!(matches!(
314 CohereVisionEmbeddings::new(bad_model),
315 Err(EmbeddingError::Config(_))
316 ));
317
318 let ok = CohereVisionEmbeddings::new(config_with("http://127.0.0.1:1".into())).unwrap();
319 assert_eq!(ok.dimension(), COHERE_VISION_DIMENSION);
320 assert_eq!(ok.model_name(), COHERE_VISION_EMBED_MODEL);
321 }
322
323 #[test]
324 fn image_format_maps_known_mimes() {
325 assert_eq!(
326 CohereVisionEmbeddings::image_format("image/png").unwrap(),
327 "png"
328 );
329 assert_eq!(
330 CohereVisionEmbeddings::image_format("image/jpeg").unwrap(),
331 "jpeg"
332 );
333 assert_eq!(
334 CohereVisionEmbeddings::image_format("image/webp").unwrap(),
335 "webp"
336 );
337 assert!(CohereVisionEmbeddings::image_format("image/bmp").is_err());
338 }
339
340 #[tokio::test]
342 async fn image_request_body_matches_v2_snapshot() {
343 let response = json!({"embeddings": {"float": [[0.6, 0.8], [0.8, -0.6]]}});
344 let (base_url, bodies) = spawn_json_recording_stub(response).await;
345 let embeddings = CohereVisionEmbeddings::new(config_with(base_url)).unwrap();
346
347 let images = vec![
348 ImageInput::from_base64("aW1n", "image/png"),
349 ImageInput::from_data_uri("data:image/jpeg;base64,amZlZw"),
350 ];
351 let vectors = embeddings.embed_images(&images).await.unwrap();
352 assert_eq!(vectors.len(), 2);
353 for v in &vectors {
355 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
356 assert!((norm - 1.0).abs() < 1e-5);
357 }
358
359 let recorded = bodies.lock().unwrap();
360 assert_eq!(recorded.len(), 1);
361 let body = &recorded[0];
362 assert_eq!(body["model"], COHERE_VISION_EMBED_MODEL);
363 assert_eq!(body["input_type"], "image");
364 assert_eq!(body["embedding_types"][0], "float");
365 assert_eq!(
366 body["images"],
367 json!([
368 {"image_bytes": {"bytes": "aW1n"}, "format": "png"},
369 {"image_bytes": {"bytes": "amZlZw"}, "format": "jpeg"},
370 ])
371 );
372 }
373
374 #[tokio::test]
375 async fn text_request_uses_configured_input_type() {
376 let response = json!({"embeddings": {"float": [[0.6, 0.8]]}});
377 let (base_url, bodies) = spawn_json_recording_stub(response).await;
378 let cfg = config_with(base_url).with_text_input_type(CohereEmbedInputType::SearchDocument);
379 let embeddings = CohereVisionEmbeddings::new(cfg).unwrap();
380
381 let v = embeddings.embed_text("a red shoe").await.unwrap();
382 assert_eq!(v.len(), 2);
383
384 let recorded = bodies.lock().unwrap();
385 assert_eq!(recorded[0]["input_type"], "search_document");
386 assert_eq!(recorded[0]["texts"][0], "a red shoe");
387 }
388
389 #[tokio::test]
392 async fn plain_url_images_are_rejected() {
393 let response = json!({"embeddings": {"float": [[0.6, 0.8]]}});
394 let (base_url, _bodies) = spawn_json_recording_stub(response).await;
395 let embeddings = CohereVisionEmbeddings::new(config_with(base_url)).unwrap();
396
397 let err = embeddings
398 .embed_image(&ImageInput::from_url("https://example.com/a.png"))
399 .await
400 .unwrap_err();
401 assert!(matches!(err, EmbeddingError::Config(_)));
402 }
403
404 #[tokio::test]
405 async fn batch_mismatch_is_reported() {
406 let response = json!({"embeddings": {"float": [[0.6, 0.8]]}});
408 let (base_url, _bodies) = spawn_json_recording_stub(response).await;
409 let embeddings = CohereVisionEmbeddings::new(config_with(base_url)).unwrap();
410
411 let images = vec![
412 ImageInput::from_base64("aW1n", "image/png"),
413 ImageInput::from_base64("amZlZw", "image/jpeg"),
414 ];
415 let err = embeddings.embed_images(&images).await.unwrap_err();
416 assert!(matches!(
417 err,
418 EmbeddingError::BatchMismatch {
419 expected: 2,
420 actual: 1
421 }
422 ));
423 }
424
425 #[tokio::test]
426 async fn retries_on_429_and_rejects_empty_inputs() {
427 let success_body = r#"{"embeddings":{"float":[[0.6,0.8]]}}"#;
428 let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
429 let embeddings = CohereVisionEmbeddings::new(config_with(base_url)).unwrap();
430
431 let v = embeddings
432 .embed_text("hello")
433 .await
434 .expect("should retry after two 429s");
435 assert_eq!(v.len(), 2);
436 assert_eq!(requests.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
437
438 assert!(matches!(
439 embeddings.embed_text(" ").await,
440 Err(EmbeddingError::EmptyInput)
441 ));
442 assert_eq!(
443 embeddings.embed_images(&[]).await.unwrap(),
444 Vec::<Vec<f32>>::new()
445 );
446 }
447}