use async_trait::async_trait;
use lc_core::http::{HttpClient, RequestOptions};
use serde::Deserialize;
use serde_json::json;
use crate::{EmbeddingError, Embeddings};
pub const COHERE_EMBED_MODEL: &str = "embed-english-v3.0";
pub const COHERE_EMBED_BASE_URL: &str = "https://api.cohere.com/v2";
pub const COHERE_MAX_BATCH: usize = 96;
#[derive(Debug, Clone, Copy)]
pub enum CohereEmbedInputType {
SearchQuery,
SearchDocument,
Classification,
Clustering,
}
impl CohereEmbedInputType {
pub(crate) fn as_str(&self) -> &'static str {
match self {
CohereEmbedInputType::SearchQuery => "search_query",
CohereEmbedInputType::SearchDocument => "search_document",
CohereEmbedInputType::Classification => "classification",
CohereEmbedInputType::Clustering => "clustering",
}
}
}
#[derive(Clone)]
pub struct CohereEmbeddingsConfig {
pub api_key: String,
pub base_url: String,
pub model: String,
pub input_type: CohereEmbedInputType,
}
impl std::fmt::Debug for CohereEmbeddingsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CohereEmbeddingsConfig")
.field("api_key", &crate::RedactedDebug(&self.api_key))
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("input_type", &self.input_type)
.finish()
}
}
impl Default for CohereEmbeddingsConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: COHERE_EMBED_BASE_URL.to_string(),
model: COHERE_EMBED_MODEL.to_string(),
input_type: CohereEmbedInputType::SearchQuery,
}
}
}
impl CohereEmbeddingsConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
..Default::default()
}
}
pub fn from_env_result() -> Result<Self, EmbeddingError> {
let api_key = std::env::var("COHERE_API_KEY").map_err(|_| {
EmbeddingError::Config("COHERE_API_KEY environment variable not set".to_string())
})?;
let base_url =
std::env::var("COHERE_BASE_URL").unwrap_or_else(|_| COHERE_EMBED_BASE_URL.to_string());
let model =
std::env::var("COHERE_EMBED_MODEL").unwrap_or_else(|_| COHERE_EMBED_MODEL.to_string());
Ok(Self {
api_key,
base_url,
model,
..Default::default()
})
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
self.input_type = input_type;
self
}
}
#[derive(Debug, Deserialize)]
struct CohereEmbedResponse {
embeddings: CohereEmbeddingsBody,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CohereEmbeddingsBody {
Typed(CohereTypedEmbeddings),
Flat(Vec<Vec<f32>>),
}
#[derive(Debug, Default, Deserialize)]
struct CohereTypedEmbeddings {
#[serde(default)]
float: Vec<Vec<f32>>,
}
impl CohereEmbeddingsBody {
fn into_float_vectors(self) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let vectors = match self {
CohereEmbeddingsBody::Typed(typed) => typed.float,
CohereEmbeddingsBody::Flat(vectors) => vectors,
};
if vectors.is_empty() {
return Err(EmbeddingError::ParseError(
"Cohere response contains no float embeddings (expected embeddings.float)"
.to_string(),
));
}
Ok(vectors)
}
}
pub struct CohereEmbeddings {
config: CohereEmbeddingsConfig,
http: HttpClient,
dimension: usize,
}
impl std::fmt::Debug for CohereEmbeddings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CohereEmbeddings")
.field("model", &self.config.model)
.finish()
}
}
impl CohereEmbeddings {
pub fn new(config: CohereEmbeddingsConfig) -> Result<Self, EmbeddingError> {
if config.api_key.trim().is_empty() {
return Err(EmbeddingError::Config(
"COHERE_API_KEY is empty".to_string(),
));
}
let dimension = Self::dimension_for(&config.model)?;
let http = HttpClient::api()
.build()
.map_err(|e| EmbeddingError::Config(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
config,
http,
dimension,
})
}
fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
match model {
"embed-english-v3.0" | "embed-multilingual-v3.0" => Ok(1024),
other => Err(EmbeddingError::Config(format!(
"unknown embedding dimension for Cohere model '{other}' \
(supported: 'embed-english-v3.0', 'embed-multilingual-v3.0')"
))),
}
}
pub fn from_env_result() -> Result<Self, EmbeddingError> {
let config = CohereEmbeddingsConfig::from_env_result()?;
Self::new(config)
}
fn request_options(&self) -> RequestOptions {
RequestOptions::new().bearer(self.config.api_key.clone())
}
fn map_http_error(err: lc_core::http::HttpError) -> EmbeddingError {
match err {
lc_core::http::HttpError::Status { status, body } => {
EmbeddingError::ApiError(format!("HTTP {status}: {body}"))
}
other => EmbeddingError::HttpError(other.to_string()),
}
}
async fn embed_chunk(
&self,
texts: &[&str],
input_type: CohereEmbedInputType,
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let url = format!("{}/embed", self.config.base_url);
let body = json!({
"model": self.config.model,
"input_type": input_type.as_str(),
"texts": texts,
"embedding_types": ["float"],
});
let response = self
.http
.post_json_with(&url, &body, self.request_options())
.await
.map_err(Self::map_http_error)?;
let embed_response: CohereEmbedResponse =
serde_json::from_str(&response.body).map_err(|e| {
let preview: String = response.body.chars().take(200).collect();
EmbeddingError::ParseError(format!("{e} - body: {preview}"))
})?;
let vectors = embed_response.embeddings.into_float_vectors()?;
if vectors.len() != texts.len() {
return Err(EmbeddingError::BatchMismatch {
expected: texts.len(),
actual: vectors.len(),
});
}
Ok(vectors)
}
}
#[async_trait]
impl Embeddings for CohereEmbeddings {
async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
if text.trim().is_empty() {
return Err(EmbeddingError::EmptyInput);
}
let mut vectors = self.embed_chunk(&[text], self.config.input_type).await?;
let mut embedding = vectors.swap_remove(0);
crate::l2_normalize(&mut embedding);
Ok(embedding)
}
async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
if texts.iter().any(|t| t.trim().is_empty()) {
return Err(EmbeddingError::EmptyInput);
}
let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
for chunk in texts.chunks(COHERE_MAX_BATCH) {
let mut chunk_vectors = self.embed_chunk(chunk, self.config.input_type).await?;
for v in chunk_vectors.iter_mut() {
crate::l2_normalize(v);
}
embeddings.append(&mut chunk_vectors);
}
Ok(embeddings)
}
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.config.model
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{
spawn_json_handler_stub, spawn_json_recording_stub, spawn_status_stub,
};
use std::sync::atomic::Ordering;
use std::sync::Arc;
#[tokio::test]
async fn test_embed_query_retries_on_429() {
let success_body = r#"{"embeddings":{"float":[[0.6,0.8]]}}"#;
let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
let config = CohereEmbeddingsConfig {
api_key: "test-key".into(),
base_url,
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::SearchQuery,
};
let embeddings = CohereEmbeddings::new(config).unwrap();
let v = embeddings
.embed_query("hello")
.await
.expect("should retry successfully after two 429s");
assert_eq!(v.len(), 2);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm);
assert_eq!(requests.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
}
#[tokio::test]
async fn test_embed_documents_truncated_errors() {
let base_url = spawn_json_handler_stub(Arc::new(move |body: serde_json::Value| {
let wanted = body
.get("texts")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let n = wanted.saturating_sub(1);
let vectors: Vec<serde_json::Value> =
(0..n).map(|_| serde_json::json!([0.1, 0.2])).collect();
serde_json::json!({ "embeddings": { "float": vectors } })
}))
.await
.0;
let config = CohereEmbeddingsConfig {
api_key: "test-key".into(),
base_url,
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::SearchDocument,
};
let embeddings = CohereEmbeddings::new(config).unwrap();
let result = embeddings.embed_documents(&["a", "b"]).await;
assert!(
matches!(
result,
Err(EmbeddingError::BatchMismatch {
expected: 2,
actual: 1
})
),
"truncated response should report BatchMismatch, got: {:?}",
result
);
}
#[tokio::test]
async fn test_embed_documents_chunks_at_96_and_preserves_order() {
use std::sync::atomic::AtomicUsize;
let next_global_index = Arc::new(AtomicUsize::new(0));
let handler_index = next_global_index.clone();
let (base_url, bodies) =
spawn_json_handler_stub(Arc::new(move |body: serde_json::Value| {
let wanted = body
.get("texts")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let vectors: Vec<serde_json::Value> = (0..wanted)
.map(|_| {
let global = handler_index.fetch_add(1, Ordering::SeqCst) as f32;
serde_json::json!([global, 1.0])
})
.collect();
serde_json::json!({ "embeddings": { "float": vectors } })
}))
.await;
let config = CohereEmbeddingsConfig {
api_key: "test-key".into(),
base_url,
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::SearchDocument,
};
let embeddings = CohereEmbeddings::new(config).unwrap();
let texts: Vec<String> = (0..200).map(|i| format!("text-{i}")).collect();
let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
let result = embeddings.embed_documents(&refs).await.unwrap();
assert_eq!(result.len(), 200);
let chunk_sizes: Vec<usize> = bodies
.lock()
.unwrap()
.iter()
.map(|b| {
b.get("texts")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0)
})
.collect();
assert_eq!(chunk_sizes, vec![96, 96, 8]);
for i in [0usize, 95, 96, 199] {
let ratio = result[i][0] / result[i][1];
assert!(
(ratio - i as f32).abs() < 0.05,
"vector {i} misaligned: ratio {ratio}"
);
}
}
#[tokio::test]
async fn test_embed_documents_honors_configured_input_type() {
let (base_url, bodies) = spawn_json_recording_stub(serde_json::json!({
"embeddings": { "float": [[0.6, 0.8]] }
}))
.await;
let config = CohereEmbeddingsConfig {
api_key: "test-key".into(),
base_url,
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::Classification,
};
let embeddings = CohereEmbeddings::new(config).unwrap();
embeddings.embed_documents(&["a"]).await.unwrap();
assert_eq!(bodies.lock().unwrap()[0]["input_type"], "classification");
}
#[tokio::test]
async fn test_embed_documents_non_2xx_surfaces_status_and_body() {
let (base_url, requests) =
spawn_status_stub(400, 1, 200, r#"{"embeddings":{"float":[]}}"#).await;
let config = CohereEmbeddingsConfig {
api_key: "test-key".into(),
base_url,
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::SearchDocument,
};
let embeddings = CohereEmbeddings::new(config).unwrap();
let err = embeddings.embed_documents(&["a"]).await.unwrap_err();
match err {
EmbeddingError::ApiError(msg) => {
assert!(msg.contains("HTTP 400"), "message: {msg}");
assert!(msg.contains("transient"), "message: {msg}");
}
other => panic!("expected ApiError, got {other:?}"),
}
assert_eq!(
requests.load(Ordering::SeqCst),
1,
"400 must not be retried"
);
}
#[test]
fn test_config_new() {
let config = CohereEmbeddingsConfig::new("test-key");
assert_eq!(config.api_key, "test-key");
assert_eq!(config.model, COHERE_EMBED_MODEL);
}
#[test]
fn test_config_builder() {
let config = CohereEmbeddingsConfig::new("key")
.with_model("embed-multilingual-v3.0")
.with_input_type(CohereEmbedInputType::SearchDocument);
assert_eq!(config.model, "embed-multilingual-v3.0");
assert!(matches!(
config.input_type,
CohereEmbedInputType::SearchDocument
));
}
#[test]
fn test_input_type_str() {
assert_eq!(CohereEmbedInputType::SearchQuery.as_str(), "search_query");
assert_eq!(
CohereEmbedInputType::SearchDocument.as_str(),
"search_document"
);
assert_eq!(
CohereEmbedInputType::Classification.as_str(),
"classification"
);
assert_eq!(CohereEmbedInputType::Clustering.as_str(), "clustering");
}
#[test]
fn test_embeddings_new() {
let config = CohereEmbeddingsConfig::new("key");
let embeddings = CohereEmbeddings::new(config).unwrap();
assert_eq!(embeddings.model_name(), COHERE_EMBED_MODEL);
assert_eq!(embeddings.dimension(), 1024);
}
#[test]
fn test_new_rejects_empty_api_key() {
let config = CohereEmbeddingsConfig {
api_key: String::new(),
base_url: COHERE_EMBED_BASE_URL.into(),
model: COHERE_EMBED_MODEL.into(),
input_type: CohereEmbedInputType::SearchDocument,
};
let err = CohereEmbeddings::new(config).unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
}
#[test]
fn test_new_rejects_unknown_model() {
let config = CohereEmbeddingsConfig::new("key").with_model("some-unknown-model");
let err = CohereEmbeddings::new(config).unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
}
#[tokio::test]
async fn test_empty_input_contract() {
let embeddings = CohereEmbeddings::new(CohereEmbeddingsConfig::new("key")).unwrap();
assert!(matches!(
embeddings.embed_query("").await,
Err(EmbeddingError::EmptyInput)
));
assert!(matches!(
embeddings.embed_query(" ").await,
Err(EmbeddingError::EmptyInput)
));
assert_eq!(
embeddings.embed_documents(&[]).await.unwrap(),
Vec::<Vec<f32>>::new()
);
assert!(matches!(
embeddings.embed_documents(&["ok", " "]).await,
Err(EmbeddingError::EmptyInput)
));
}
}