use crate::config::EmbedConfig;
use std::io;
#[allow(async_fn_in_trait)]
pub trait Embedder {
async fn embed_batch(&self, texts: &[String]) -> io::Result<Vec<Vec<f32>>>;
}
#[cfg(test)]
pub struct StubEmbedder {
pub dim: usize,
}
#[cfg(test)]
impl StubEmbedder {
pub fn new() -> Self {
StubEmbedder { dim: 16 }
}
fn vec_for(&self, text: &str) -> Vec<f32> {
let mut v = vec![0f32; self.dim];
let mut h: u64 = 0xcbf29ce484222325;
for b in text.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
for (i, slot) in v.iter_mut().enumerate() {
let hh = h.wrapping_add(i as u64).wrapping_mul(0x9E3779B97F4A7C15);
*slot = ((hh >> 16) & 0xffff) as f32 / 65535.0;
}
v
}
}
#[cfg(test)]
impl Default for StubEmbedder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl Embedder for StubEmbedder {
async fn embed_batch(&self, texts: &[String]) -> io::Result<Vec<Vec<f32>>> {
Ok(texts.iter().map(|t| self.vec_for(t)).collect())
}
}
pub struct EndpointEmbedder {
client: reqwest::Client,
base_url: String,
model: String,
api_key: String,
}
impl EndpointEmbedder {
pub fn new(cfg: &EmbedConfig, proxy: Option<&str>) -> io::Result<Self> {
let client = crate::net::build_client(proxy).map_err(|e| io::Error::other(e.to_string()))?;
let api_key = std::env::var("EMBED_API_KEY")
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.unwrap_or_default();
Ok(EndpointEmbedder {
client,
base_url: cfg.base_url.clone(),
model: cfg.model.clone(),
api_key,
})
}
}
const MAX_EMBED_CHARS: usize = 2000;
fn cap_embed_input(text: &str) -> String {
if text.chars().count() > MAX_EMBED_CHARS {
text.chars().take(MAX_EMBED_CHARS).collect()
} else {
text.to_string()
}
}
impl Embedder for EndpointEmbedder {
async fn embed_batch(&self, texts: &[String]) -> io::Result<Vec<Vec<f32>>> {
let capped: Vec<String> = texts.iter().map(|t| cap_embed_input(t)).collect();
let body = serde_json::json!({ "model": self.model, "input": capped }).to_string();
let mut req = self
.client
.post(format!("{}/embeddings", self.base_url))
.header("content-type", "application/json");
if !self.api_key.is_empty() {
req = req.header("authorization", format!("Bearer {}", self.api_key));
}
let text = req
.body(body)
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| io::Error::other(format!("embed endpoint failed: {e}")))?
.text()
.await
.map_err(|e| io::Error::other(format!("embed read failed: {e}")))?;
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let data = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no data[] in embeddings response"))?;
if data.len() != texts.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("embeddings count mismatch: got {} for {} inputs", data.len(), texts.len()),
));
}
let mut out = Vec::with_capacity(data.len());
for item in data {
let arr = item
.get("embedding")
.and_then(|e| e.as_array())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no embedding[] in data item"))?;
out.push(arr.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect());
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_embed_input_truncates_long_only() {
let short = "hello world";
assert_eq!(cap_embed_input(short), short);
let long: String = "x".repeat(MAX_EMBED_CHARS + 500);
assert_eq!(cap_embed_input(&long).chars().count(), MAX_EMBED_CHARS);
}
#[tokio::test]
async fn stub_is_deterministic_and_order_preserving() {
let e = StubEmbedder::new();
let a = e.embed_batch(&["foo".into(), "bar".into(), "foo".into()]).await.unwrap();
assert_eq!(a.len(), 3);
assert_eq!(a[0].len(), 16);
assert_eq!(a[0], a[2]); assert_ne!(a[0], a[1]);
}
#[tokio::test]
async fn endpoint_parses_embeddings_response() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await.unwrap();
let json = r#"{"data":[{"embedding":[0.1,0.2,0.3]},{"embedding":[0.4,0.5,0.6]}]}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
json.len(), json
);
sock.write_all(resp.as_bytes()).await.unwrap();
});
let cfg = EmbedConfig { base_url: format!("http://{addr}"), model: "m".into(), batch_size: 64, store: "x".into() };
let emb = EndpointEmbedder::new(&cfg, None).unwrap();
let out = emb.embed_batch(&["a".into(), "b".into()]).await.unwrap();
server.await.unwrap();
assert_eq!(out, vec![vec![0.1f32, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
}
}