use serde::{Deserialize, Serialize}; use super::client::LlmClient; use crate::error::AgentError; // ── Embedding types ──────────────────────────────────────────── #[derive(Serialize)] struct EmbeddingRequest { model: String, input: Vec, } #[derive(Deserialize)] struct EmbeddingResponse { data: Vec, } #[derive(Deserialize)] struct EmbeddingData { embedding: Vec, index: usize, } /// Max inputs per embedding request. The bge/OpenAI-like backends cap the input /// array (bge-multilingual-gemma2 rejects >25 with "batch size overflow"), so we /// chunk larger corpora — a whole control catalog (~1.8k) would otherwise 500. const EMBED_BATCH_SIZE: usize = 16; // ── Embedding implementation ─────────────────────────────────── impl LlmClient { pub fn embed_model(&self) -> &str { &self.embed_model } /// Generate embeddings for a batch of texts, chunking into backend-sized /// requests and preserving input order across chunks. pub async fn embed(&self, texts: Vec) -> Result>, AgentError> { if texts.is_empty() { return Ok(Vec::new()); } let mut out = Vec::with_capacity(texts.len()); for chunk in texts.chunks(EMBED_BATCH_SIZE) { out.extend(self.embed_batch(chunk.to_vec()).await?); } Ok(out) } /// Embed one backend-sized batch (≤ [`EMBED_BATCH_SIZE`]) in a single request. async fn embed_batch(&self, texts: Vec) -> Result>, AgentError> { let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/')); let request_body = EmbeddingRequest { model: self.embed_model.clone(), input: texts, }; let mut req = self .http .post(&url) .header("content-type", "application/json") .json(&request_body); if let Some(auth) = self.auth_header() { req = req.header("Authorization", auth); } let resp = req .send() .await .map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(AgentError::Other(format!( "Embedding API returned {status}: {body}" ))); } let body: EmbeddingResponse = resp .json() .await .map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?; let mut data = body.data; data.sort_by_key(|d| d.index); Ok(data.into_iter().map(|d| d.embedding).collect()) } } #[cfg(test)] mod tests { use super::*; use secrecy::SecretString; fn client() -> LlmClient { LlmClient::new( "http://unused".into(), SecretString::from(String::new()), "m".into(), "e".into(), ) } #[tokio::test] async fn empty_input_makes_no_request() { // Must short-circuit before any HTTP call (base_url is unroutable). let out = client().embed(Vec::new()).await.unwrap(); assert!(out.is_empty()); } #[test] fn batch_size_is_within_backend_cap() { assert!( EMBED_BATCH_SIZE <= 25, "must stay under the bge 25-input cap" ); } }