uni_xervo/provider/llamacpp/
embed.rs1use super::{
44 LlamaCppCommon, MIN_MAX_INPUT_TOKENS, PROVIDER_ID, post_json, required_option,
45 resolve_tokenizer_base_url,
46};
47use crate::error::{Result, RuntimeError};
48use crate::provider::remote_common::parse_openai_embeddings_response;
49use crate::traits::{EmbedResult, EmbeddingModel};
50use async_trait::async_trait;
51use futures::future::try_join_all;
52use reqwest::Client;
53use serde_json::{Value, json};
54
55#[derive(Debug, Clone)]
61pub(crate) struct EmbedConfig {
62 pub common: LlamaCppCommon,
64 pub tokenizer_base_url: String,
66 pub max_input_tokens: usize,
68 pub dimensions: u32,
70}
71
72impl EmbedConfig {
73 pub(crate) fn from_options(options: &Value) -> Result<Self> {
76 let common = LlamaCppCommon::from_options(options)?;
77
78 let tokenizer_base_url = match options.get("tokenizer_base_url").and_then(|v| v.as_str()) {
79 Some(raw) if !raw.trim().is_empty() => raw.trim().trim_end_matches('/').to_string(),
80 _ => resolve_tokenizer_base_url(&common.base_url),
81 };
82
83 let max_input_tokens = options
84 .get("max_input_tokens")
85 .and_then(|v| v.as_u64())
86 .ok_or_else(|| required_option("max_input_tokens"))?
87 as usize;
88 if max_input_tokens < MIN_MAX_INPUT_TOKENS {
89 return Err(RuntimeError::Config(format!(
90 "Option 'max_input_tokens' for provider '{}' must be at least {} \
91 (two special tokens plus one content token)",
92 PROVIDER_ID, MIN_MAX_INPUT_TOKENS
93 )));
94 }
95
96 let dimensions = options
97 .get("embedding_dimensions")
98 .and_then(|v| v.as_u64())
99 .filter(|d| *d > 0 && *d <= u32::MAX as u64)
100 .ok_or_else(|| required_option("embedding_dimensions"))?
101 as u32;
102
103 Ok(Self {
104 common,
105 tokenizer_base_url,
106 max_input_tokens,
107 dimensions,
108 })
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) enum EmbedInput {
119 Text(String),
121 Tokens(Vec<u32>),
123}
124
125impl EmbedInput {
126 pub(crate) fn to_json(&self) -> Value {
127 match self {
128 EmbedInput::Text(t) => Value::String(t.clone()),
129 EmbedInput::Tokens(ids) => json!(ids),
130 }
131 }
132}
133
134pub(crate) fn needs_tokenize(text: &str, max_input_tokens: usize) -> bool {
140 text.chars().count() > max_input_tokens.saturating_sub(2)
141}
142
143pub(crate) fn plan_input(text: &str, tokens: &[u32], max_input_tokens: usize) -> EmbedInput {
147 if tokens.is_empty() || tokens.len() <= max_input_tokens {
148 return EmbedInput::Text(text.to_string());
149 }
150 let keep = max_input_tokens.saturating_sub(1).max(1);
151 let mut bounded: Vec<u32> = tokens[..keep].to_vec();
152 bounded.push(*tokens.last().expect("non-empty"));
153 EmbedInput::Tokens(bounded)
154}
155
156pub(crate) fn validate_dimensions(vectors: &[Vec<f32>], expected: u32) -> Result<()> {
158 for (i, v) in vectors.iter().enumerate() {
159 if v.len() != expected as usize {
160 return Err(RuntimeError::ApiError(format!(
161 "llama.cpp returned a {}-dimensional vector at index {}, expected {} \
162 (check the embedding_dimensions option)",
163 v.len(),
164 i,
165 expected
166 )));
167 }
168 }
169 Ok(())
170}
171
172pub struct LlamaCppEmbeddingModel {
178 client: Client,
179 cb: crate::reliability::CircuitBreakerWrapper,
180 model_id: String,
181 cfg: EmbedConfig,
182}
183
184impl LlamaCppEmbeddingModel {
185 pub(crate) fn new(
186 client: Client,
187 cb: crate::reliability::CircuitBreakerWrapper,
188 model_id: String,
189 cfg: EmbedConfig,
190 ) -> Self {
191 Self {
192 client,
193 cb,
194 model_id,
195 cfg,
196 }
197 }
198
199 async fn post_json(&self, url: String, payload: &Value) -> Result<Value> {
201 post_json(
202 &self.client,
203 &self.cfg.common,
204 url,
205 payload,
206 Some(self.cfg.max_input_tokens),
207 )
208 .await
209 }
210
211 async fn tokenize(&self, text: &str) -> Result<Vec<u32>> {
214 let body = self
215 .post_json(
216 format!("{}/tokenize", self.cfg.tokenizer_base_url),
217 &json!({
218 "model": self.model_id,
222 "content": text,
223 "add_special": true,
224 "parse_special": false,
225 }),
226 )
227 .await?;
228 let tokens = body
229 .get("tokens")
230 .and_then(|t| t.as_array())
231 .ok_or_else(|| {
232 RuntimeError::ApiError(
233 "llama.cpp /tokenize response malformed: missing 'tokens' array".to_string(),
234 )
235 })?;
236 tokens
237 .iter()
238 .enumerate()
239 .map(|(i, t)| {
240 t.as_u64()
241 .filter(|v| *v <= u32::MAX as u64)
242 .map(|v| v as u32)
243 .ok_or_else(|| {
244 RuntimeError::ApiError(format!(
245 "llama.cpp /tokenize response malformed: token {} is not an integer id",
246 i
247 ))
248 })
249 })
250 .collect()
251 }
252
253 async fn plan_inputs(&self, texts: &[String]) -> Result<Vec<EmbedInput>> {
256 let max = self.cfg.max_input_tokens;
257 try_join_all(texts.iter().map(|text| async move {
258 if !needs_tokenize(text, max) {
259 return Ok(EmbedInput::Text(text.clone()));
260 }
261 let tokens = self.tokenize(text).await?;
262 Ok(plan_input(text, &tokens, max))
263 }))
264 .await
265 }
266
267 async fn post_embeddings(&self, inputs: &[EmbedInput]) -> Result<Value> {
268 let input: Vec<Value> = inputs.iter().map(EmbedInput::to_json).collect();
269 self.post_json(
270 format!("{}/embeddings", self.cfg.common.base_url),
271 &json!({ "model": self.model_id, "input": input }),
272 )
273 .await
274 }
275
276 async fn embed_pipeline(&self, texts: &[String]) -> Result<EmbedResult> {
277 let inputs = self.plan_inputs(texts).await?;
278 let body = self.post_embeddings(&inputs).await?;
279 let result = parse_openai_embeddings_response("llama.cpp", &body, Some(texts.len()))?;
280 validate_dimensions(&result.vectors, self.cfg.dimensions)?;
281 Ok(result)
282 }
283}
284
285#[async_trait]
286impl EmbeddingModel for LlamaCppEmbeddingModel {
287 async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
288 if texts.is_empty() {
289 return Ok(EmbedResult {
290 vectors: Vec::new(),
291 usage: None,
292 });
293 }
294 let texts: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
295
296 self.cb
302 .call(move || async move {
303 match self.embed_pipeline(&texts).await {
304 Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
305 other => other.map(Ok),
306 }
307 })
308 .await?
309 }
310
311 fn dimensions(&self) -> u32 {
312 self.cfg.dimensions
313 }
314}
315
316impl crate::traits::ModelInfo for LlamaCppEmbeddingModel {
317 fn model_id(&self) -> &str {
318 &self.model_id
319 }
320}
321
322#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::provider::llamacpp::DEFAULT_REQUEST_TIMEOUT_SECS;
330 use std::time::Duration;
331
332 fn options() -> Value {
333 json!({
334 "base_url": "http://127.0.0.1:8080/v1",
335 "max_input_tokens": 512,
336 "embedding_dimensions": 384
337 })
338 }
339
340 #[test]
343 fn config_happy_path_and_defaults() {
344 let cfg = EmbedConfig::from_options(&options()).unwrap();
345 assert_eq!(cfg.common.base_url, "http://127.0.0.1:8080/v1");
346 assert_eq!(cfg.tokenizer_base_url, "http://127.0.0.1:8080");
347 assert_eq!(cfg.max_input_tokens, 512);
348 assert_eq!(cfg.dimensions, 384);
349 assert!(cfg.common.api_key.is_none());
350 assert_eq!(
351 cfg.common.request_timeout,
352 Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
353 );
354 }
355
356 #[test]
357 fn config_strips_trailing_slash_and_honours_explicit_tokenizer_url_and_timeout() {
358 let cfg = EmbedConfig::from_options(&json!({
359 "base_url": "http://h:1/v1/",
360 "tokenizer_base_url": "http://tok:2/",
361 "max_input_tokens": 3,
362 "embedding_dimensions": 4,
363 "request_timeout_secs": 7
364 }))
365 .unwrap();
366 assert_eq!(cfg.common.base_url, "http://h:1/v1");
367 assert_eq!(cfg.tokenizer_base_url, "http://tok:2");
368 assert_eq!(cfg.common.request_timeout, Duration::from_secs(7));
369 }
370
371 #[test]
372 fn config_requires_base_url_max_tokens_and_dimensions() {
373 for missing in ["base_url", "max_input_tokens", "embedding_dimensions"] {
374 let mut o = options();
375 o.as_object_mut().unwrap().remove(missing);
376 let err = EmbedConfig::from_options(&o).expect_err("must fail");
377 match err {
378 RuntimeError::Config(msg) => assert!(msg.contains(missing), "{msg}"),
379 other => panic!("expected Config, got {other:?}"),
380 }
381 }
382 assert!(EmbedConfig::from_options(&Value::Null).is_err());
383 }
384
385 #[test]
386 fn config_rejects_tiny_max_input_tokens() {
387 let mut o = options();
388 o["max_input_tokens"] = json!(2);
389 let err = EmbedConfig::from_options(&o).err().unwrap();
390 assert!(err.to_string().contains("at least 3"), "{err}");
391 }
392
393 #[test]
394 fn config_rejects_zero_dimensions() {
395 let mut o = options();
396 o["embedding_dimensions"] = json!(0);
397 assert!(EmbedConfig::from_options(&o).is_err());
398 }
399
400 #[test]
403 fn needs_tokenize_boundaries_use_char_count() {
404 assert!(!needs_tokenize("", 10));
405 assert!(!needs_tokenize("12345678", 10)); assert!(needs_tokenize("123456789", 10)); assert!(!needs_tokenize("你好世界你好世界", 10));
409 assert!(needs_tokenize("你好世界你好世界你", 10));
410 assert!(needs_tokenize("a", 1));
412 assert!(!needs_tokenize("", 0));
413 }
414
415 #[test]
416 fn plan_input_fits_sends_text() {
417 let tokens: Vec<u32> = (0..10).collect();
418 assert_eq!(
419 plan_input("t", &tokens, 10),
420 EmbedInput::Text("t".to_string())
421 );
422 assert_eq!(plan_input("t", &[], 10), EmbedInput::Text("t".to_string()));
423 }
424
425 #[test]
426 fn plan_input_truncates_keeping_leading_ids_and_trailing_special() {
427 let mut tokens = vec![101u32];
429 tokens.extend(1..=9);
430 tokens.push(102);
431 match plan_input("t", &tokens, 10) {
432 EmbedInput::Tokens(ids) => {
433 assert_eq!(ids.len(), 10);
434 assert_eq!(ids[0], 101);
435 assert_eq!(&ids[1..9], &[1, 2, 3, 4, 5, 6, 7, 8]);
436 assert_eq!(*ids.last().unwrap(), 102);
437 }
438 other => panic!("expected Tokens, got {other:?}"),
439 }
440 }
441
442 #[test]
443 fn embed_input_json_shapes() {
444 assert_eq!(
445 EmbedInput::Text("hi".into()).to_json(),
446 Value::String("hi".into())
447 );
448 assert_eq!(EmbedInput::Tokens(vec![1, 2]).to_json(), json!([1, 2]));
449 }
450
451 #[test]
454 fn dimension_validation() {
455 assert!(validate_dimensions(&[vec![0.0; 4], vec![1.0; 4]], 4).is_ok());
456 let err = validate_dimensions(&[vec![0.0; 4], vec![1.0; 3]], 4)
457 .err()
458 .unwrap();
459 let msg = err.to_string();
460 assert!(
461 msg.contains("3-dimensional") && msg.contains("index 1"),
462 "{msg}"
463 );
464 assert!(msg.contains("embedding_dimensions"), "{msg}");
465 }
466}