1use crate::api::{ModelAliasSpec, ModelTask};
45use crate::error::{Result, RuntimeError};
46use crate::provider::remote_common::{RemoteProviderBase, parse_openai_embeddings_response};
47use crate::traits::{
48 EmbedResult, EmbeddingModel, LoadedModelHandle, ModelProvider, ProviderCapabilities,
49 ProviderHealth,
50};
51use async_trait::async_trait;
52use futures::future::try_join_all;
53use reqwest::Client;
54use serde_json::{Value, json};
55use std::sync::Arc;
56use std::time::Duration;
57
58pub const PROVIDER_ID: &str = "remote/llamacpp";
60
61pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
63
64pub const MIN_MAX_INPUT_TOKENS: usize = 3;
67
68pub struct RemoteLlamaCppProvider {
71 base: RemoteProviderBase,
72}
73
74impl Default for RemoteLlamaCppProvider {
75 fn default() -> Self {
76 Self {
77 base: RemoteProviderBase::new(),
78 }
79 }
80}
81
82impl RemoteLlamaCppProvider {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 #[cfg(test)]
88 fn insert_test_breaker(&self, key: crate::api::ModelRuntimeKey, age: Duration) {
89 self.base.insert_test_breaker(key, age);
90 }
91
92 #[cfg(test)]
93 fn breaker_count(&self) -> usize {
94 self.base.breaker_count()
95 }
96
97 #[cfg(test)]
98 fn force_cleanup_now_for_test(&self) {
99 self.base.force_cleanup_now_for_test();
100 }
101}
102
103#[async_trait]
104impl ModelProvider for RemoteLlamaCppProvider {
105 fn provider_id(&self) -> &'static str {
106 PROVIDER_ID
107 }
108
109 fn capabilities(&self) -> ProviderCapabilities {
110 ProviderCapabilities {
111 supported_tasks: vec![ModelTask::Embed],
112 }
113 }
114
115 async fn load(&self, spec: &ModelAliasSpec) -> Result<LoadedModelHandle> {
116 match spec.task {
117 ModelTask::Embed => {
118 let cfg = LlamaCppConfig::from_options(&spec.options)?;
119 let model = LlamaCppEmbeddingModel {
120 client: self.base.client.clone(),
121 cb: self.base.circuit_breaker_for(spec),
122 model_id: spec.model_id.clone(),
123 cfg,
124 };
125 let handle: Arc<dyn EmbeddingModel> = Arc::new(model);
126 Ok(Arc::new(handle) as LoadedModelHandle)
127 }
128 other => Err(RuntimeError::CapabilityMismatch(format!(
129 "llama.cpp provider does not support task {:?}",
130 other
131 ))),
132 }
133 }
134
135 async fn health(&self) -> ProviderHealth {
136 ProviderHealth::Healthy
137 }
138}
139
140#[derive(Debug, Clone)]
146pub(crate) struct LlamaCppConfig {
147 pub base_url: String,
149 pub tokenizer_base_url: String,
151 pub max_input_tokens: usize,
153 pub dimensions: u32,
155 pub api_key: Option<String>,
157 pub request_timeout: Duration,
159}
160
161impl LlamaCppConfig {
162 pub(crate) fn from_options(options: &Value) -> Result<Self> {
165 let cfg_err = |msg: String| RuntimeError::Config(msg);
166 let required = |key: &str| {
167 cfg_err(format!(
168 "Option '{}' for provider '{}' is required",
169 key, PROVIDER_ID
170 ))
171 };
172
173 let base_url = options
174 .get("base_url")
175 .and_then(|v| v.as_str())
176 .map(|s| s.trim().trim_end_matches('/').to_string())
177 .filter(|s| !s.is_empty())
178 .ok_or_else(|| required("base_url"))?;
179
180 let tokenizer_base_url = match options.get("tokenizer_base_url").and_then(|v| v.as_str()) {
181 Some(raw) if !raw.trim().is_empty() => raw.trim().trim_end_matches('/').to_string(),
182 _ => resolve_tokenizer_base_url(&base_url),
183 };
184
185 let max_input_tokens = options
186 .get("max_input_tokens")
187 .and_then(|v| v.as_u64())
188 .ok_or_else(|| required("max_input_tokens"))? as usize;
189 if max_input_tokens < MIN_MAX_INPUT_TOKENS {
190 return Err(cfg_err(format!(
191 "Option 'max_input_tokens' for provider '{}' must be at least {} \
192 (two special tokens plus one content token)",
193 PROVIDER_ID, MIN_MAX_INPUT_TOKENS
194 )));
195 }
196
197 let dimensions = options
198 .get("embedding_dimensions")
199 .and_then(|v| v.as_u64())
200 .filter(|d| *d > 0 && *d <= u32::MAX as u64)
201 .ok_or_else(|| required("embedding_dimensions"))? as u32;
202
203 let api_key = match options.get("api_key_env").and_then(|v| v.as_str()) {
204 None => None,
205 Some(env_name) => Some(
206 std::env::var(env_name)
207 .map_err(|_| cfg_err(format!("{} env var not set", env_name)))?,
208 ),
209 };
210
211 let request_timeout = Duration::from_secs(
212 options
213 .get("request_timeout_secs")
214 .and_then(|v| v.as_u64())
215 .filter(|s| *s > 0)
216 .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS),
217 );
218
219 Ok(Self {
220 base_url,
221 tokenizer_base_url,
222 max_input_tokens,
223 dimensions,
224 api_key,
225 request_timeout,
226 })
227 }
228}
229
230pub(crate) fn resolve_tokenizer_base_url(base_url: &str) -> String {
241 let trimmed = base_url.trim_end_matches('/');
242 match trimmed.strip_suffix("/v1") {
243 Some(root) if !root.is_empty() => root.to_string(),
244 _ => trimmed.to_string(),
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum EmbedInput {
255 Text(String),
257 Tokens(Vec<u32>),
259}
260
261impl EmbedInput {
262 pub(crate) fn to_json(&self) -> Value {
263 match self {
264 EmbedInput::Text(t) => Value::String(t.clone()),
265 EmbedInput::Tokens(ids) => json!(ids),
266 }
267 }
268}
269
270pub(crate) fn needs_tokenize(text: &str, max_input_tokens: usize) -> bool {
276 text.chars().count() > max_input_tokens.saturating_sub(2)
277}
278
279pub(crate) fn plan_input(text: &str, tokens: &[u32], max_input_tokens: usize) -> EmbedInput {
283 if tokens.is_empty() || tokens.len() <= max_input_tokens {
284 return EmbedInput::Text(text.to_string());
285 }
286 let keep = max_input_tokens.saturating_sub(1).max(1);
287 let mut bounded: Vec<u32> = tokens[..keep].to_vec();
288 bounded.push(*tokens.last().expect("non-empty"));
289 EmbedInput::Tokens(bounded)
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub(crate) enum LlamaCppErrorKind {
299 InputTooLarge,
302 ExceedsContext,
304 Other,
306}
307
308const ERROR_SNIPPET_MAX_CHARS: usize = 200;
309
310pub(crate) fn classify_error_body(body: &str) -> (LlamaCppErrorKind, String) {
314 let parsed: Option<Value> = serde_json::from_str(body).ok();
315 let err_obj = parsed.as_ref().and_then(|v| v.get("error"));
316 let message = err_obj
317 .and_then(|e| e.get("message"))
318 .and_then(|m| m.as_str())
319 .map(|s| s.to_string())
320 .unwrap_or_else(|| body.chars().take(ERROR_SNIPPET_MAX_CHARS).collect());
321 let err_type = err_obj
322 .and_then(|e| e.get("type"))
323 .and_then(|t| t.as_str())
324 .unwrap_or("");
325
326 let lower = message.to_ascii_lowercase();
327 let kind = if lower.contains("too large to process") {
328 LlamaCppErrorKind::InputTooLarge
329 } else if err_type == "exceed_context_size"
330 || lower.contains("exceeds the available context size")
331 {
332 LlamaCppErrorKind::ExceedsContext
333 } else {
334 LlamaCppErrorKind::Other
335 };
336 (kind, message)
337}
338
339pub(crate) fn map_llamacpp_status(
347 status: u16,
348 body: &str,
349 max_input_tokens: usize,
350) -> RuntimeError {
351 let (kind, message) = classify_error_body(body);
352 match kind {
353 LlamaCppErrorKind::InputTooLarge | LlamaCppErrorKind::ExceedsContext => {
354 RuntimeError::InferenceError(format!(
355 "llama.cpp server rejected the input length ({}); max_input_tokens={} is \
356 larger than the server allows — lower max_input_tokens or start llama-server \
357 with --ubatch-size and --ctx-size of at least that many tokens",
358 message, max_input_tokens
359 ))
360 }
361 LlamaCppErrorKind::Other => match status {
362 429 => RuntimeError::RateLimited,
363 401 | 403 => RuntimeError::Unauthorized,
364 500..=599 => RuntimeError::Unavailable,
365 _ => RuntimeError::ApiError(format!("llama.cpp API error: {}: {}", status, message)),
366 },
367 }
368}
369
370fn map_reqwest_error(e: reqwest::Error) -> RuntimeError {
372 if e.is_timeout() {
373 RuntimeError::Timeout
374 } else {
375 RuntimeError::ApiError(e.to_string())
376 }
377}
378
379pub(crate) fn validate_dimensions(vectors: &[Vec<f32>], expected: u32) -> Result<()> {
381 for (i, v) in vectors.iter().enumerate() {
382 if v.len() != expected as usize {
383 return Err(RuntimeError::ApiError(format!(
384 "llama.cpp returned a {}-dimensional vector at index {}, expected {} \
385 (check the embedding_dimensions option)",
386 v.len(),
387 i,
388 expected
389 )));
390 }
391 }
392 Ok(())
393}
394
395pub struct LlamaCppEmbeddingModel {
401 client: Client,
402 cb: crate::reliability::CircuitBreakerWrapper,
403 model_id: String,
404 cfg: LlamaCppConfig,
405}
406
407impl LlamaCppEmbeddingModel {
408 fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
409 match &self.cfg.api_key {
410 Some(key) => req.header("Authorization", format!("Bearer {}", key)),
411 None => req,
412 }
413 }
414
415 async fn post_json(&self, url: String, payload: &Value) -> Result<Value> {
417 let response = self
418 .apply_auth(self.client.post(url))
419 .timeout(self.cfg.request_timeout)
420 .json(payload)
421 .send()
422 .await
423 .map_err(map_reqwest_error)?;
424
425 let status = response.status().as_u16();
426 let text = response.text().await.map_err(map_reqwest_error)?;
427 if !(200..300).contains(&status) {
428 return Err(map_llamacpp_status(
429 status,
430 &text,
431 self.cfg.max_input_tokens,
432 ));
433 }
434 serde_json::from_str(&text).map_err(|e| {
435 RuntimeError::ApiError(format!("llama.cpp returned malformed JSON: {}", e))
436 })
437 }
438
439 async fn tokenize(&self, text: &str) -> Result<Vec<u32>> {
442 let body = self
443 .post_json(
444 format!("{}/tokenize", self.cfg.tokenizer_base_url),
445 &json!({
446 "model": self.model_id,
450 "content": text,
451 "add_special": true,
452 "parse_special": false,
453 }),
454 )
455 .await?;
456 let tokens = body
457 .get("tokens")
458 .and_then(|t| t.as_array())
459 .ok_or_else(|| {
460 RuntimeError::ApiError(
461 "llama.cpp /tokenize response malformed: missing 'tokens' array".to_string(),
462 )
463 })?;
464 tokens
465 .iter()
466 .enumerate()
467 .map(|(i, t)| {
468 t.as_u64()
469 .filter(|v| *v <= u32::MAX as u64)
470 .map(|v| v as u32)
471 .ok_or_else(|| {
472 RuntimeError::ApiError(format!(
473 "llama.cpp /tokenize response malformed: token {} is not an integer id",
474 i
475 ))
476 })
477 })
478 .collect()
479 }
480
481 async fn plan_inputs(&self, texts: &[String]) -> Result<Vec<EmbedInput>> {
484 let max = self.cfg.max_input_tokens;
485 try_join_all(texts.iter().map(|text| async move {
486 if !needs_tokenize(text, max) {
487 return Ok(EmbedInput::Text(text.clone()));
488 }
489 let tokens = self.tokenize(text).await?;
490 Ok(plan_input(text, &tokens, max))
491 }))
492 .await
493 }
494
495 async fn post_embeddings(&self, inputs: &[EmbedInput]) -> Result<Value> {
496 let input: Vec<Value> = inputs.iter().map(EmbedInput::to_json).collect();
497 self.post_json(
498 format!("{}/embeddings", self.cfg.base_url),
499 &json!({ "model": self.model_id, "input": input }),
500 )
501 .await
502 }
503
504 async fn embed_pipeline(&self, texts: &[String]) -> Result<EmbedResult> {
505 let inputs = self.plan_inputs(texts).await?;
506 let body = self.post_embeddings(&inputs).await?;
507 let result = parse_openai_embeddings_response("llama.cpp", &body, Some(texts.len()))?;
508 validate_dimensions(&result.vectors, self.cfg.dimensions)?;
509 Ok(result)
510 }
511}
512
513#[async_trait]
514impl EmbeddingModel for LlamaCppEmbeddingModel {
515 async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
516 if texts.is_empty() {
517 return Ok(EmbedResult {
518 vectors: Vec::new(),
519 usage: None,
520 });
521 }
522 let texts: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
523
524 self.cb
530 .call(move || async move {
531 match self.embed_pipeline(&texts).await {
532 Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
533 other => other.map(Ok),
534 }
535 })
536 .await?
537 }
538
539 fn dimensions(&self) -> u32 {
540 self.cfg.dimensions
541 }
542}
543
544impl crate::traits::ModelInfo for LlamaCppEmbeddingModel {
545 fn model_id(&self) -> &str {
546 &self.model_id
547 }
548}
549
550#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::api::ModelRuntimeKey;
558 use crate::provider::remote_common::RemoteProviderBase;
559
560 static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
561
562 fn options() -> Value {
563 json!({
564 "base_url": "http://127.0.0.1:8080/v1",
565 "max_input_tokens": 512,
566 "embedding_dimensions": 384
567 })
568 }
569
570 fn spec(alias: &str, task: ModelTask, model_id: &str, options: Value) -> ModelAliasSpec {
571 ModelAliasSpec {
572 alias: alias.to_string(),
573 task,
574 provider_id: PROVIDER_ID.to_string(),
575 model_id: model_id.to_string(),
576 revision: None,
577 warmup: crate::api::WarmupPolicy::Lazy,
578 required: false,
579 timeout: None,
580 load_timeout: None,
581 retry: None,
582 options,
583 }
584 }
585
586 #[tokio::test]
589 async fn provider_id_and_capabilities() {
590 let p = RemoteLlamaCppProvider::new();
591 assert_eq!(p.provider_id(), "remote/llamacpp");
592 assert_eq!(p.capabilities().supported_tasks, vec![ModelTask::Embed]);
593 assert!(matches!(p.health().await, ProviderHealth::Healthy));
594 }
595
596 #[tokio::test]
597 async fn load_rejects_non_embed_tasks() {
598 let p = RemoteLlamaCppProvider::new();
599 for task in [ModelTask::Rerank, ModelTask::Generate, ModelTask::Raw] {
600 let err = p
601 .load(&spec("x/y", task, "bge", options()))
602 .await
603 .expect_err("must fail");
604 assert!(
605 matches!(err, RuntimeError::CapabilityMismatch(_)),
606 "{err:?}"
607 );
608 }
609 }
610
611 #[tokio::test]
612 async fn load_exposes_configured_dimensions_and_model_id() {
613 let p = RemoteLlamaCppProvider::new();
614 let handle = p
615 .load(&spec("embed/a", ModelTask::Embed, "bge-small", options()))
616 .await
617 .unwrap();
618 let model = handle.downcast_ref::<Arc<dyn EmbeddingModel>>().unwrap();
619 assert_eq!(model.dimensions(), 384);
620 assert_eq!(model.model_id(), "bge-small");
621 }
622
623 #[tokio::test]
624 async fn breaker_reused_for_same_runtime_key() {
625 let p = RemoteLlamaCppProvider::new();
626 let _ = p
627 .load(&spec("embed/a", ModelTask::Embed, "bge", options()))
628 .await
629 .unwrap();
630 let _ = p
631 .load(&spec("embed/b", ModelTask::Embed, "bge", options()))
632 .await
633 .unwrap();
634 assert_eq!(p.breaker_count(), 1);
635 }
636
637 #[tokio::test]
638 async fn breaker_isolated_by_model() {
639 let p = RemoteLlamaCppProvider::new();
640 let _ = p
641 .load(&spec("embed/a", ModelTask::Embed, "bge", options()))
642 .await
643 .unwrap();
644 let _ = p
645 .load(&spec("embed/b", ModelTask::Embed, "nomic", options()))
646 .await
647 .unwrap();
648 assert_eq!(p.breaker_count(), 2);
649 }
650
651 #[tokio::test]
652 async fn breaker_cleanup_evicts_stale_entries() {
653 let p = RemoteLlamaCppProvider::new();
654 let stale = spec("embed/stale", ModelTask::Embed, "old", options());
655 let fresh = spec("embed/fresh", ModelTask::Embed, "new", options());
656 p.insert_test_breaker(
657 ModelRuntimeKey::new(&stale),
658 RemoteProviderBase::BREAKER_TTL + Duration::from_secs(5),
659 );
660 p.insert_test_breaker(ModelRuntimeKey::new(&fresh), Duration::from_secs(1));
661 assert_eq!(p.breaker_count(), 2);
662 p.force_cleanup_now_for_test();
663 let _ = p.load(&fresh).await.unwrap();
664 assert_eq!(p.breaker_count(), 1);
665 }
666
667 #[test]
670 fn config_happy_path_and_defaults() {
671 let cfg = LlamaCppConfig::from_options(&options()).unwrap();
672 assert_eq!(cfg.base_url, "http://127.0.0.1:8080/v1");
673 assert_eq!(cfg.tokenizer_base_url, "http://127.0.0.1:8080");
674 assert_eq!(cfg.max_input_tokens, 512);
675 assert_eq!(cfg.dimensions, 384);
676 assert!(cfg.api_key.is_none());
677 assert_eq!(
678 cfg.request_timeout,
679 Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
680 );
681 }
682
683 #[test]
684 fn config_strips_trailing_slash_and_honours_explicit_tokenizer_url_and_timeout() {
685 let cfg = LlamaCppConfig::from_options(&json!({
686 "base_url": "http://h:1/v1/",
687 "tokenizer_base_url": "http://tok:2/",
688 "max_input_tokens": 3,
689 "embedding_dimensions": 4,
690 "request_timeout_secs": 7
691 }))
692 .unwrap();
693 assert_eq!(cfg.base_url, "http://h:1/v1");
694 assert_eq!(cfg.tokenizer_base_url, "http://tok:2");
695 assert_eq!(cfg.request_timeout, Duration::from_secs(7));
696 }
697
698 #[test]
699 fn config_requires_base_url_max_tokens_and_dimensions() {
700 for missing in ["base_url", "max_input_tokens", "embedding_dimensions"] {
701 let mut o = options();
702 o.as_object_mut().unwrap().remove(missing);
703 let err = LlamaCppConfig::from_options(&o).expect_err("must fail");
704 match err {
705 RuntimeError::Config(msg) => assert!(msg.contains(missing), "{msg}"),
706 other => panic!("expected Config, got {other:?}"),
707 }
708 }
709 assert!(LlamaCppConfig::from_options(&Value::Null).is_err());
710 }
711
712 #[test]
713 fn config_rejects_tiny_max_input_tokens() {
714 let mut o = options();
715 o["max_input_tokens"] = json!(2);
716 let err = LlamaCppConfig::from_options(&o).err().unwrap();
717 assert!(err.to_string().contains("at least 3"), "{err}");
718 }
719
720 #[tokio::test]
721 async fn config_api_key_env_present_and_missing() {
722 let _lock = ENV_LOCK.lock().await;
723 let mut o = options();
724 o["api_key_env"] = json!("UNI_XERVO_LLAMACPP_TEST_KEY");
725
726 unsafe { std::env::remove_var("UNI_XERVO_LLAMACPP_TEST_KEY") };
728 let err = LlamaCppConfig::from_options(&o).err().unwrap();
729 assert!(
730 err.to_string().contains("UNI_XERVO_LLAMACPP_TEST_KEY"),
731 "{err}"
732 );
733
734 unsafe { std::env::set_var("UNI_XERVO_LLAMACPP_TEST_KEY", "sekrit") };
736 let cfg = LlamaCppConfig::from_options(&o).unwrap();
737 assert_eq!(cfg.api_key.as_deref(), Some("sekrit"));
738 unsafe { std::env::remove_var("UNI_XERVO_LLAMACPP_TEST_KEY") };
740 }
741
742 #[test]
743 fn tokenizer_base_url_derivation() {
744 assert_eq!(
745 resolve_tokenizer_base_url("http://h:8080/v1"),
746 "http://h:8080"
747 );
748 assert_eq!(
749 resolve_tokenizer_base_url("http://h:8080/v1/"),
750 "http://h:8080"
751 );
752 assert_eq!(
753 resolve_tokenizer_base_url("http://h:8080/api/v1"),
754 "http://h:8080/api"
755 );
756 assert_eq!(resolve_tokenizer_base_url("http://h:8080"), "http://h:8080");
757 assert_eq!(
758 resolve_tokenizer_base_url("http://h:8080/v10"),
759 "http://h:8080/v10"
760 );
761 assert_eq!(resolve_tokenizer_base_url("/v1"), "/v1");
762 }
763
764 #[test]
767 fn needs_tokenize_boundaries_use_char_count() {
768 assert!(!needs_tokenize("", 10));
769 assert!(!needs_tokenize("12345678", 10)); assert!(needs_tokenize("123456789", 10)); assert!(!needs_tokenize("你好世界你好世界", 10));
773 assert!(needs_tokenize("你好世界你好世界你", 10));
774 assert!(needs_tokenize("a", 1));
776 assert!(!needs_tokenize("", 0));
777 }
778
779 #[test]
780 fn plan_input_fits_sends_text() {
781 let tokens: Vec<u32> = (0..10).collect();
782 assert_eq!(
783 plan_input("t", &tokens, 10),
784 EmbedInput::Text("t".to_string())
785 );
786 assert_eq!(plan_input("t", &[], 10), EmbedInput::Text("t".to_string()));
787 }
788
789 #[test]
790 fn plan_input_truncates_keeping_leading_ids_and_trailing_special() {
791 let mut tokens = vec![101u32];
793 tokens.extend(1..=9);
794 tokens.push(102);
795 match plan_input("t", &tokens, 10) {
796 EmbedInput::Tokens(ids) => {
797 assert_eq!(ids.len(), 10);
798 assert_eq!(ids[0], 101);
799 assert_eq!(&ids[1..9], &[1, 2, 3, 4, 5, 6, 7, 8]);
800 assert_eq!(*ids.last().unwrap(), 102);
801 }
802 other => panic!("expected Tokens, got {other:?}"),
803 }
804 }
805
806 #[test]
807 fn embed_input_json_shapes() {
808 assert_eq!(
809 EmbedInput::Text("hi".into()).to_json(),
810 Value::String("hi".into())
811 );
812 assert_eq!(EmbedInput::Tokens(vec![1, 2]).to_json(), json!([1, 2]));
813 }
814
815 #[test]
818 fn classify_error_bodies() {
819 let too_large = r#"{"error":{"code":500,"message":"input is too large to process. increase the physical batch size","type":"server_error"}}"#;
820 let (k, m) = classify_error_body(too_large);
821 assert_eq!(k, LlamaCppErrorKind::InputTooLarge);
822 assert!(m.starts_with("input is too large"));
823
824 let ctx_type =
825 r#"{"error":{"code":400,"message":"whatever","type":"exceed_context_size"}}"#;
826 assert_eq!(
827 classify_error_body(ctx_type).0,
828 LlamaCppErrorKind::ExceedsContext
829 );
830 let ctx_msg = r#"{"error":{"message":"prompt exceeds the available context size. increase context size"}}"#;
831 assert_eq!(
832 classify_error_body(ctx_msg).0,
833 LlamaCppErrorKind::ExceedsContext
834 );
835
836 let (k, m) = classify_error_body("<html>502 bad gateway</html>");
837 assert_eq!(k, LlamaCppErrorKind::Other);
838 assert_eq!(m, "<html>502 bad gateway</html>");
839
840 let long = "x".repeat(1000);
841 assert_eq!(
842 classify_error_body(&long).1.chars().count(),
843 ERROR_SNIPPET_MAX_CHARS
844 );
845 }
846
847 #[test]
848 fn status_mapping() {
849 let too_large = r#"{"error":{"message":"input is too large to process. increase the physical batch size"}}"#;
850 let e = map_llamacpp_status(500, too_large, 512);
851 assert!(matches!(e, RuntimeError::InferenceError(_)), "{e:?}");
852 assert!(!e.is_retryable());
853 assert!(e.to_string().contains("max_input_tokens=512"), "{e}");
854
855 let e = map_llamacpp_status(
856 400,
857 r#"{"error":{"type":"exceed_context_size","message":"x"}}"#,
858 512,
859 );
860 assert!(matches!(e, RuntimeError::InferenceError(_)), "{e:?}");
861
862 assert!(matches!(
863 map_llamacpp_status(500, "boom", 512),
864 RuntimeError::Unavailable
865 ));
866 assert!(matches!(
867 map_llamacpp_status(503, "", 512),
868 RuntimeError::Unavailable
869 ));
870 assert!(matches!(
871 map_llamacpp_status(429, "", 512),
872 RuntimeError::RateLimited
873 ));
874 assert!(matches!(
875 map_llamacpp_status(401, "", 512),
876 RuntimeError::Unauthorized
877 ));
878 assert!(matches!(
879 map_llamacpp_status(403, "", 512),
880 RuntimeError::Unauthorized
881 ));
882 match map_llamacpp_status(404, r#"{"error":{"message":"no route"}}"#, 512) {
883 RuntimeError::ApiError(m) => {
884 assert!(m.contains("404") && m.contains("no route"), "{m}")
885 }
886 other => panic!("{other:?}"),
887 }
888 }
889
890 #[test]
891 fn dimension_validation() {
892 assert!(validate_dimensions(&[vec![0.0; 4], vec![1.0; 4]], 4).is_ok());
893 let err = validate_dimensions(&[vec![0.0; 4], vec![1.0; 3]], 4)
894 .err()
895 .unwrap();
896 let msg = err.to_string();
897 assert!(
898 msg.contains("3-dimensional") && msg.contains("index 1"),
899 "{msg}"
900 );
901 assert!(msg.contains("embedding_dimensions"), "{msg}");
902 }
903}