#[cfg(all(
feature = "tokio-runtime",
any(feature = "embeddings", feature = "static-embeddings"),
not(target_arch = "wasm32")
))]
use liter_llm::{EmbeddingFormat, EmbeddingInput, EmbeddingRequest, LlmClient};
#[cfg(all(
feature = "tokio-runtime",
any(feature = "embeddings", feature = "static-embeddings"),
not(target_arch = "wasm32")
))]
use crate::core::config::LlmConfig;
#[cfg(all(
feature = "tokio-runtime",
any(feature = "embeddings", feature = "static-embeddings"),
not(target_arch = "wasm32")
))]
pub(crate) async fn embed_via_llm<T: AsRef<str>>(
texts: &[T],
config: &LlmConfig,
normalize: bool,
) -> crate::Result<(Vec<Vec<f32>>, Option<crate::types::LlmUsage>)> {
if texts.is_empty() {
return Ok((Vec::new(), None));
}
let client = super::client::create_client(config)?;
let input_strings: Vec<String> = texts.iter().map(|t| t.as_ref().to_string()).collect();
let input = if input_strings.len() == 1 {
EmbeddingInput::Single(input_strings.into_iter().next().expect("checked non-empty"))
} else {
EmbeddingInput::Multiple(input_strings)
};
let request = EmbeddingRequest {
model: config.model.clone(),
input,
encoding_format: Some(EmbeddingFormat::Base64),
dimensions: None,
user: None,
};
let response = client.embed(request).await.map_err(|e| {
crate::XbergError::embedding(format!("LLM embedding request failed (model={}): {e}", config.model))
})?;
let usage = super::usage::extract_usage_from_embedding(&response, "embeddings");
let mut data = response.data;
data.sort_by_key(|obj| obj.index);
let sorted_indices: Vec<u32> = data.iter().map(|obj| obj.index).collect();
validate_contiguous_indices(&sorted_indices, texts.len(), &config.model)?;
let mut embeddings: Vec<Vec<f32>> = data.into_iter().map(|obj| obj.embedding).collect();
if normalize {
for embedding in &mut embeddings {
normalize_l2(embedding);
}
}
Ok((embeddings, usage))
}
#[cfg(any(
all(
feature = "tokio-runtime",
any(feature = "embeddings", feature = "static-embeddings"),
not(target_arch = "wasm32")
),
test
))]
fn validate_contiguous_indices(sorted_indices: &[u32], expected_len: usize, model: &str) -> crate::Result<()> {
let is_contiguous = sorted_indices.len() == expected_len
&& sorted_indices
.iter()
.enumerate()
.all(|(position, &index)| position as u64 == u64::from(index));
if is_contiguous {
return Ok(());
}
Err(crate::XbergError::embedding(format!(
"LLM embedding response incomplete or non-contiguous (model={model}): expected indices 0..{expected_len} \
(one per input text), got {got} objects with indices {sorted_indices:?}",
got = sorted_indices.len(),
)))
}
#[cfg(any(
all(
feature = "tokio-runtime",
any(feature = "embeddings", feature = "static-embeddings"),
not(target_arch = "wasm32")
),
test
))]
fn normalize_l2(embedding: &mut [f32]) {
let magnitude: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if magnitude > f32::EPSILON {
let inv_mag = 1.0 / magnitude;
embedding.iter_mut().for_each(|x| *x *= inv_mag);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_l2_unit_vector() {
let mut v = vec![1.0f32, 0.0, 0.0];
normalize_l2(&mut v);
assert!((v[0] - 1.0).abs() < f32::EPSILON);
assert!((v[1]).abs() < f32::EPSILON);
}
#[test]
fn test_normalize_l2_arbitrary_vector() {
let mut v = vec![3.0f32, 4.0];
normalize_l2(&mut v);
let magnitude: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((magnitude - 1.0).abs() < 1e-6);
}
#[test]
fn test_normalize_l2_zero_vector() {
let mut v = vec![0.0f32, 0.0, 0.0];
normalize_l2(&mut v);
assert!(v.iter().all(|&x| x == 0.0));
}
#[test]
fn should_accept_complete_contiguous_indices() {
let sorted_indices = vec![0u32, 1, 2, 3];
let result = validate_contiguous_indices(&sorted_indices, 4, "test-model");
assert!(result.is_ok());
}
#[test]
fn should_reject_short_index_set_missing_last_entry() {
let sorted_indices = vec![0u32, 1, 2];
let result = validate_contiguous_indices(&sorted_indices, 4, "test-model");
let err = result.expect_err("short index set must be rejected");
let message = err.to_string();
assert!(message.contains("test-model"), "error should name the model: {message}");
assert!(
message.contains("0..4"),
"error should state the expected range: {message}"
);
}
#[test]
fn should_reject_index_set_with_gap() {
let sorted_indices = vec![0u32, 1, 3];
let result = validate_contiguous_indices(&sorted_indices, 4, "test-model");
assert!(result.is_err(), "a gap in indices must be rejected");
}
#[test]
fn should_reject_index_set_with_duplicate() {
let sorted_indices = vec![0u32, 1, 1, 2];
let result = validate_contiguous_indices(&sorted_indices, 4, "test-model");
assert!(result.is_err(), "a duplicate index must be rejected");
}
#[test]
fn base64_embedding_response_matches_float_response_bit_exact() {
use base64::Engine as _;
let floats: [f32; 5] = [1.0, -2.5, 12.375, 0.0, f32::MIN_POSITIVE];
let mut bytes = Vec::with_capacity(floats.len() * 4);
for value in floats {
bytes.extend_from_slice(&value.to_le_bytes());
}
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let base64_body = format!(r#"{{"object":"embedding","index":0,"embedding":{encoded:?}}}"#);
let float_body = format!(
r#"{{"object":"embedding","index":0,"embedding":{floats}}}"#,
floats = serde_json::to_string(&floats.to_vec()).expect("serialize float array")
);
let base64_object: liter_llm::EmbeddingObject =
serde_json::from_str(&base64_body).expect("base64 embedding response should deserialize");
let float_object: liter_llm::EmbeddingObject =
serde_json::from_str(&float_body).expect("float array embedding response should deserialize");
assert_eq!(
base64_object.embedding, floats,
"base64-decoded floats must match the source bit-exactly"
);
assert_eq!(
base64_object.embedding, float_object.embedding,
"base64 and float-array response shapes must decode to identical vectors"
);
}
}