use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::memory::config::EmbeddingConfig;
use crate::memory::error::EmbeddingError;
#[allow(dead_code)]
#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError>;
fn model_id(&self) -> &str;
fn dim(&self) -> usize;
fn query_prefix(&self) -> &str;
fn document_prefix(&self) -> &str;
}
#[derive(Serialize)]
struct EmbedRequest<'a> {
model: &'a str,
input: &'a [String],
}
#[derive(Deserialize)]
struct EmbedResponse {
data: Vec<EmbedData>,
}
#[derive(Deserialize)]
struct EmbedData {
embedding: Vec<f32>,
}
pub struct OpenAiCompatibleEmbedder {
client: reqwest::Client,
base_url: String,
model: String,
configured_dim: usize,
detected_dim: AtomicUsize,
query_prefix: String,
document_prefix: String,
api_key: Option<String>,
}
impl OpenAiCompatibleEmbedder {
#[allow(dead_code)]
pub fn new(cfg: &EmbeddingConfig, api_key: Option<String>) -> Result<Self, EmbeddingError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|_| EmbeddingError::Network)?;
Ok(Self {
client,
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
configured_dim: cfg.dim,
detected_dim: AtomicUsize::new(0),
query_prefix: cfg.query_prefix.clone(),
document_prefix: cfg.document_prefix.clone(),
api_key,
})
}
#[allow(dead_code)]
pub async fn embed_documents(&self, raw: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let prefixed: Vec<String> = raw
.iter()
.map(|t| apply_prefix(&self.document_prefix, t))
.collect();
self.embed(&prefixed).await
}
#[allow(dead_code)]
pub async fn embed_query(&self, raw: &str) -> Result<Vec<f32>, EmbeddingError> {
let prefixed = vec![apply_prefix(&self.query_prefix, raw)];
let mut vecs = self.embed(&prefixed).await?;
vecs.pop()
.ok_or_else(|| EmbeddingError::Malformed("empty data array".into()))
}
async fn call_embeddings(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let url = format!("{}/embeddings", self.base_url);
let body = EmbedRequest {
model: &self.model,
input: texts,
};
let mut builder = self.client.post(&url).json(&body);
if let Some(key) = &self.api_key {
if !key.is_empty() {
builder = builder.header("authorization", format!("Bearer {key}"));
}
}
let response = builder.send().await.map_err(|e| {
if e.is_timeout() {
EmbeddingError::Timeout
} else {
EmbeddingError::Network
}
})?;
let status = response.status();
match status.as_u16() {
401 | 403 => return Err(EmbeddingError::Auth),
429 => return Err(EmbeddingError::RateLimited),
200..=299 => {} s => {
let snippet = response
.text()
.await
.unwrap_or_default()
.chars()
.take(256)
.collect::<String>();
return Err(EmbeddingError::Http(format!("HTTP {s} — {snippet}")));
}
}
let parsed: EmbedResponse = response
.json()
.await
.map_err(|e| EmbeddingError::Malformed(e.to_string()))?;
if parsed.data.is_empty() {
return Err(EmbeddingError::Malformed("empty data array".into()));
}
let mut effective_dim = if self.configured_dim > 0 {
self.configured_dim
} else {
self.detected_dim.load(Ordering::Acquire) };
let mut out = Vec::with_capacity(parsed.data.len());
for item in parsed.data {
let got = item.embedding.len();
if effective_dim > 0 {
if got != effective_dim {
return Err(EmbeddingError::Dim {
expected: effective_dim,
got,
});
}
} else {
if got == 0 {
return Err(EmbeddingError::Malformed(
"zero-dimension embedding response".into(),
));
}
match self.detected_dim.compare_exchange(
0,
got,
Ordering::AcqRel, Ordering::Acquire, ) {
Ok(_) => {
effective_dim = got;
}
Err(winner) => {
if got != winner {
return Err(EmbeddingError::Dim {
expected: winner,
got,
});
}
effective_dim = winner;
}
}
}
out.push(item.embedding);
}
Ok(out)
}
}
#[async_trait]
impl EmbeddingProvider for OpenAiCompatibleEmbedder {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
self.call_embeddings(texts).await
}
fn model_id(&self) -> &str {
&self.model
}
fn dim(&self) -> usize {
if self.configured_dim > 0 {
self.configured_dim
} else {
self.detected_dim.load(Ordering::Acquire)
}
}
fn query_prefix(&self) -> &str {
&self.query_prefix
}
fn document_prefix(&self) -> &str {
&self.document_prefix
}
}
#[allow(dead_code)]
fn apply_prefix(prefix: &str, text: &str) -> String {
if prefix.is_empty() {
text.to_string()
} else {
format!("{prefix}{text}")
}
}
#[cfg(test)]
impl OpenAiCompatibleEmbedder {
fn new_with_client(
cfg: &EmbeddingConfig,
api_key: Option<String>,
client: reqwest::Client,
) -> Self {
Self {
client,
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
configured_dim: cfg.dim,
detected_dim: AtomicUsize::new(0),
query_prefix: cfg.query_prefix.clone(),
document_prefix: cfg.document_prefix.clone(),
api_key,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::config::EmbeddingConfig;
#[test]
fn test_new_with_valid_config_returns_ok() {
let cfg = EmbeddingConfig::default();
let result: Result<OpenAiCompatibleEmbedder, EmbeddingError> =
OpenAiCompatibleEmbedder::new(&cfg, None);
assert!(
result.is_ok(),
"W1: new() with a valid config must return Ok"
);
}
fn cfg(base: &str) -> EmbeddingConfig {
EmbeddingConfig {
provider: "openai".into(),
base_url: base.into(),
model: "nomic-embed-text".into(),
dim: 3,
query_prefix: "search_query: ".into(),
document_prefix: "search_document: ".into(),
}
}
#[tokio::test]
async fn test_embed_returns_vector_of_configured_dim_and_reports_model() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/embeddings")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.1,0.2,0.3]}]}"#)
.create_async()
.await;
let emb =
OpenAiCompatibleEmbedder::new(&cfg(&server.url()), Some("ollama".into())).unwrap();
let out = emb
.embed(&["search_document: hi".to_string()])
.await
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].len(), 3);
assert_eq!(emb.model_id(), "nomic-embed-text");
m.assert_async().await;
}
#[tokio::test]
async fn test_embed_auth_failure_is_typed_error_no_panic() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/embeddings")
.with_status(401)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(&cfg(&server.url()), Some("bad".into())).unwrap();
assert!(matches!(
emb.embed(&["x".into()]).await.unwrap_err(),
EmbeddingError::Auth
));
}
#[tokio::test]
async fn test_429_is_rate_limited_typed_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/embeddings")
.with_status(429)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(&cfg(&server.url()), Some("k".into())).unwrap();
assert!(matches!(
emb.embed(&["x".into()]).await.unwrap_err(),
EmbeddingError::RateLimited
));
}
#[tokio::test]
async fn test_prefixes_are_applied_to_outgoing_request_body() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(serde_json::json!(
{"input": ["search_query: weather"]}
)))
.with_status(200)
.with_body(r#"{"data":[{"embedding":[0.0,0.0,0.0]}]}"#)
.create_async()
.await;
let emb =
OpenAiCompatibleEmbedder::new(&cfg(&server.url()), Some("ollama".into())).unwrap();
let _ = emb.embed_query("weather").await.unwrap();
m.assert_async().await;
}
#[tokio::test]
async fn test_error_messages_redact_key() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/embeddings")
.with_status(500)
.with_body("boom")
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(&cfg(&server.url()), Some("SECRET-KEY-123".into()))
.unwrap();
let msg = emb.embed(&["x".into()]).await.unwrap_err().to_string();
assert!(!msg.contains("SECRET-KEY-123"));
}
#[tokio::test]
async fn test_autodetect_dim_enforced_on_second_call() {
let mut server = mockito::Server::new_async().await;
let _m1 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(serde_json::json!(
{"input": ["hello"]}
)))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.1,0.2,0.3]}]}"#)
.create_async()
.await;
let _m2 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(serde_json::json!(
{"input": ["world"]}
)))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.1,0.2,0.3,0.4]}]}"#)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(
&EmbeddingConfig {
dim: 0, base_url: server.url(),
..Default::default()
},
None,
)
.unwrap();
let first = emb.embed(&["hello".into()]).await.unwrap();
assert_eq!(first[0].len(), 3, "Fix2: first response establishes dim=3");
assert_eq!(emb.dim(), 3, "Fix2: dim() must reflect autodetected value");
let err = emb.embed(&["world".into()]).await.unwrap_err();
assert!(
matches!(
err,
EmbeddingError::Dim {
expected: 3,
got: 4
}
),
"Fix2: expected Dim{{expected:3,got:4}}, got: {err:?}"
);
}
#[tokio::test]
async fn test_connection_refused_produces_network_error_not_timeout() {
let emb = OpenAiCompatibleEmbedder::new(
&EmbeddingConfig {
base_url: "http://127.0.0.1:1".into(),
..Default::default()
},
None,
)
.unwrap();
let err = emb.embed(&["hello".into()]).await.unwrap_err();
assert!(
matches!(err, EmbeddingError::Network),
"F1: connection-refused must produce Network, got: {err:?}"
);
}
#[tokio::test]
async fn test_autodetect_dim_cas_contract_established_before_second_call() {
let mut server = mockito::Server::new_async().await;
let _m1 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(
serde_json::json!({"input": ["first"]}),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.1,0.2,0.3,0.4]}]}"#)
.create_async()
.await;
let _m2 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(
serde_json::json!({"input": ["second"]}),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.5,0.6]}]}"#)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(
&EmbeddingConfig {
dim: 0, base_url: server.url(),
..Default::default()
},
None,
)
.unwrap();
assert_eq!(emb.dim(), 0, "F4: dim() before first call must be 0");
let first = emb.embed(&["first".into()]).await.unwrap();
assert_eq!(first[0].len(), 4, "F4: first response has 4 components");
assert_eq!(emb.dim(), 4, "F4: dim() after first call must be 4");
let err = emb.embed(&["second".into()]).await.unwrap_err();
assert!(
matches!(
err,
EmbeddingError::Dim {
expected: 4,
got: 2
}
),
"F4: CAS contract — Dim{{expected:4,got:2}} expected, got: {err:?}"
);
}
#[tokio::test]
async fn test_timeout_client_respects_deadline() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((_socket, _)) = listener.accept().await {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
});
let base_url = format!("http://127.0.0.1:{}", addr.port());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(50))
.build()
.unwrap();
let emb = OpenAiCompatibleEmbedder::new_with_client(
&EmbeddingConfig {
base_url,
..Default::default()
},
None,
client,
);
assert!(
matches!(
emb.embed(&["hello".into()]).await.unwrap_err(),
EmbeddingError::Timeout
),
"G1: stalled server beyond client deadline must produce Timeout"
);
}
#[tokio::test]
async fn test_autodetect_zero_dim_response_is_malformed() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/embeddings")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[]}]}"#)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(
&EmbeddingConfig {
dim: 0, base_url: server.url(),
..Default::default()
},
None,
)
.unwrap();
assert!(
matches!(
emb.embed(&["x".into()]).await.unwrap_err(),
EmbeddingError::Malformed(_)
),
"G4: zero-length embedding in autodetect mode must produce Malformed"
);
}
#[tokio::test]
async fn test_redirect_response_produces_http_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/embeddings")
.with_status(302)
.with_body("Found")
.create_async()
.await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let emb = OpenAiCompatibleEmbedder::new_with_client(&cfg(&server.url()), None, client);
assert!(
matches!(
emb.embed(&["x".into()]).await.unwrap_err(),
EmbeddingError::Http(_)
),
"G5: 302 response must produce EmbeddingError::Http, not Malformed"
);
}
#[tokio::test]
async fn test_autodetect_dim_cas_same_dim_on_second_call_succeeds() {
let mut server = mockito::Server::new_async().await;
let _m1 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(
serde_json::json!({"input": ["a"]}),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.1,0.2,0.3]}]}"#)
.create_async()
.await;
let _m2 = server
.mock("POST", "/embeddings")
.match_body(mockito::Matcher::PartialJson(
serde_json::json!({"input": ["b"]}),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[{"embedding":[0.4,0.5,0.6]}]}"#)
.create_async()
.await;
let emb = OpenAiCompatibleEmbedder::new(
&EmbeddingConfig {
dim: 0,
base_url: server.url(),
..Default::default()
},
None,
)
.unwrap();
let _ = emb.embed(&["a".into()]).await.unwrap();
let second = emb.embed(&["b".into()]).await.unwrap();
assert_eq!(second[0].len(), 3, "F4: same-dim second call must succeed");
}
}