1use std::fmt;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12
13#[non_exhaustive]
19#[derive(Debug)]
20pub enum EmbeddingError {
21 ModelNotLoaded,
23 DimensionMismatch {
25 expected: usize,
27 got: usize,
29 },
30 BackendError(String),
32 RateLimited {
34 retry_after: Duration,
36 },
37}
38
39impl fmt::Display for EmbeddingError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::ModelNotLoaded => write!(f, "embedding model not loaded"),
43 Self::DimensionMismatch { expected, got } => {
44 write!(f, "dimension mismatch: expected {expected}, got {got}")
45 }
46 Self::BackendError(msg) => write!(f, "embedding backend error: {msg}"),
47 Self::RateLimited { retry_after } => {
48 write!(f, "rate limited, retry after {}ms", retry_after.as_millis())
49 }
50 }
51 }
52}
53
54impl std::error::Error for EmbeddingError {}
55
56#[async_trait]
65pub trait EmbeddingProvider: Send + Sync {
66 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
68
69 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
75 let mut results = Vec::with_capacity(texts.len());
76 for text in texts {
77 results.push(self.embed(text).await?);
78 }
79 Ok(results)
80 }
81
82 fn dimensions(&self) -> usize;
84
85 fn model_name(&self) -> &str;
87
88 async fn embed_query(&self, query: &str) -> Result<Vec<f32>, EmbeddingError> {
95 self.embed(query).await
96 }
97
98 async fn warm(&self) {
106 let _ = self.embed("warm").await;
107 }
108}
109
110pub struct MockEmbeddingProvider {
119 pub dims: usize,
121}
122
123impl MockEmbeddingProvider {
124 pub fn new(dims: usize) -> Self {
126 Self { dims }
127 }
128
129 fn hash_embed(&self, text: &str) -> Vec<f32> {
131 use sha2::{Digest, Sha256};
132 let mut hasher = Sha256::new();
133 hasher.update(text.as_bytes());
134 let hash = hasher.finalize();
135
136 let mut vec = Vec::with_capacity(self.dims);
137 for i in 0..self.dims {
138 let byte = hash[i % 32];
140 vec.push((byte as f32 / 128.0) - 1.0);
141 }
142 vec
143 }
144}
145
146#[async_trait]
147impl EmbeddingProvider for MockEmbeddingProvider {
148 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
149 Ok(self.hash_embed(text))
150 }
151
152 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
153 Ok(texts.iter().map(|t| self.hash_embed(t)).collect())
154 }
155
156 fn dimensions(&self) -> usize {
157 self.dims
158 }
159
160 fn model_name(&self) -> &str {
161 "mock-sha256"
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct LlmEmbeddingConfig {
172 pub model: String,
174 pub dimensions: usize,
176 pub batch_size: usize,
178 pub api_available: bool,
180}
181
182impl Default for LlmEmbeddingConfig {
183 fn default() -> Self {
184 Self {
185 model: "text-embedding-3-small".to_string(),
186 dimensions: 384,
187 batch_size: 16,
188 api_available: false,
189 }
190 }
191}
192
193pub struct LlmEmbeddingProvider {
199 config: LlmEmbeddingConfig,
200 fallback: MockEmbeddingProvider,
201}
202
203impl LlmEmbeddingProvider {
204 pub fn new(config: LlmEmbeddingConfig) -> Self {
206 let fallback = MockEmbeddingProvider::new(config.dimensions);
207 Self { config, fallback }
208 }
209
210 pub fn from_config(table: &std::collections::HashMap<String, String>) -> Self {
216 let model = table
217 .get("model")
218 .cloned()
219 .unwrap_or_else(|| "text-embedding-3-small".to_string());
220 let dimensions = table
221 .get("dimensions")
222 .and_then(|d| d.parse::<usize>().ok())
223 .unwrap_or(384);
224 let batch_size = table
225 .get("batch_size")
226 .and_then(|b| b.parse::<usize>().ok())
227 .unwrap_or(16);
228 let api_available = table
229 .get("api_available")
230 .map(|v| v == "true")
231 .unwrap_or(false);
232
233 Self::new(LlmEmbeddingConfig {
234 model,
235 dimensions,
236 batch_size,
237 api_available,
238 })
239 }
240
241 pub fn is_api_available(&self) -> bool {
243 self.config.api_available
244 }
245
246 pub fn config(&self) -> &LlmEmbeddingConfig {
248 &self.config
249 }
250
251 async fn call_llm_api(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
257 if !self.config.api_available {
258 return Err(EmbeddingError::BackendError(
259 "LLM API not configured; using fallback".to_string(),
260 ));
261 }
262 Err(EmbeddingError::BackendError(
266 "LLM API call not yet wired to clawft-llm provider".to_string(),
267 ))
268 }
269
270 async fn call_llm_api_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
272 if !self.config.api_available {
273 return Err(EmbeddingError::BackendError(
274 "LLM API not configured; using fallback".to_string(),
275 ));
276 }
277 Err(EmbeddingError::BackendError(
278 "LLM API batch call not yet wired to clawft-llm provider".to_string(),
279 ))
280 }
281}
282
283#[async_trait]
284impl EmbeddingProvider for LlmEmbeddingProvider {
285 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
286 match self.call_llm_api(text).await {
288 Ok(vec) => {
289 if vec.len() != self.config.dimensions {
290 return Err(EmbeddingError::DimensionMismatch {
291 expected: self.config.dimensions,
292 got: vec.len(),
293 });
294 }
295 Ok(vec)
296 }
297 Err(_) => self.fallback.embed(text).await,
298 }
299 }
300
301 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
302 match self.call_llm_api_batch(texts).await {
304 Ok(vecs) => {
305 for v in &vecs {
306 if v.len() != self.config.dimensions {
307 return Err(EmbeddingError::DimensionMismatch {
308 expected: self.config.dimensions,
309 got: v.len(),
310 });
311 }
312 }
313 Ok(vecs)
314 }
315 Err(_) => self.fallback.embed_batch(texts).await,
316 }
317 }
318
319 fn dimensions(&self) -> usize {
320 self.config.dimensions
321 }
322
323 fn model_name(&self) -> &str {
324 &self.config.model
325 }
326}
327
328pub fn select_embedding_provider(
350 llm_config: Option<LlmEmbeddingConfig>,
351) -> Box<dyn EmbeddingProvider> {
352 for dir in qwen3_model_search_dirs() {
354 let model = dir.join("model_fp16.onnx");
355 let tokenizer = dir.join("tokenizer.json");
356 if model.exists() && tokenizer.exists() {
357 let provider = crate::embedding_qwen3::Qwen3EmbeddingProvider::new(&model, &tokenizer);
358 if provider.is_runtime_available() {
359 tracing::info!("Using Qwen3 embedding provider from {}", dir.display());
360 return Box::new(provider);
361 }
362 }
363 }
364
365 for path in &e5_model_search_paths() {
367 if path.exists() {
368 let provider = crate::embedding_e5::E5EmbeddingProvider::new(path);
369 if provider.is_runtime_available() {
370 tracing::info!("Using e5-small-v2 embedding provider from {}", path.display());
371 return Box::new(provider);
372 }
373 }
374 }
375
376 for path in &onnx_model_search_paths() {
378 if path.exists() {
379 let provider = crate::embedding_onnx::OnnxEmbeddingProvider::new(path);
380 if provider.is_runtime_available() {
381 tracing::info!("Using ONNX embedding provider from {}", path.display());
382 return Box::new(provider);
383 }
384 }
385 }
386
387 if let Some(config) = llm_config {
389 return Box::new(LlmEmbeddingProvider::new(config));
390 }
391
392 Box::new(MockEmbeddingProvider::new(64))
394}
395
396fn qwen3_model_search_dirs() -> Vec<std::path::PathBuf> {
402 let subdir = "Qwen3-Embedding-0.6B";
403 let mut dirs = Vec::new();
404
405 dirs.push(std::path::PathBuf::from(format!(".weftos/models/{subdir}")));
406
407 if let Some(home) = dirs_home() {
408 dirs.push(home.join(format!(".weftos/models/{subdir}")));
409 }
410
411 if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
412 let p = std::path::PathBuf::from(env_path);
413 dirs.push(p.join(subdir));
414 dirs.push(p);
415 }
416
417 dirs
418}
419
420fn onnx_model_search_paths() -> Vec<std::path::PathBuf> {
427 let model_name = "all-MiniLM-L6-v2.onnx";
428 let mut paths = Vec::new();
429
430 paths.push(std::path::PathBuf::from(format!(
432 ".weftos/models/{model_name}"
433 )));
434
435 if let Some(home) = dirs_home() {
437 paths.push(home.join(format!(".weftos/models/{model_name}")));
438 }
439
440 if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
442 paths.push(std::path::PathBuf::from(env_path));
443 }
444
445 paths
446}
447
448fn e5_model_search_paths() -> Vec<std::path::PathBuf> {
457 let subdir = "e5-small-v2";
458 let files = ["model.onnx", "model_int8.onnx", "model_fp16.onnx"];
459 let mut paths = Vec::new();
460
461 let mut push_dir = |dir: std::path::PathBuf| {
462 for f in files {
463 paths.push(dir.join(f));
464 }
465 };
466
467 push_dir(std::path::PathBuf::from(format!(".weftos/models/{subdir}")));
468 if let Some(home) = dirs_home() {
469 push_dir(home.join(format!(".weftos/models/{subdir}")));
470 }
471 if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
472 let p = std::path::PathBuf::from(env_path);
473 push_dir(p.join(subdir));
474 paths.push(p.clone());
476 for f in files {
477 paths.push(p.join(f));
478 }
479 }
480
481 paths
482}
483
484fn dirs_home() -> Option<std::path::PathBuf> {
486 std::env::var("HOME").ok().map(std::path::PathBuf::from)
487}
488
489#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[tokio::test]
496 async fn mock_embed_returns_correct_dimensions() {
497 let provider = MockEmbeddingProvider::new(64);
498 let vec = provider.embed("hello world").await.unwrap();
499 assert_eq!(vec.len(), 64);
500 }
501
502 #[tokio::test]
503 async fn mock_embed_deterministic() {
504 let provider = MockEmbeddingProvider::new(32);
505 let v1 = provider.embed("test input").await.unwrap();
506 let v2 = provider.embed("test input").await.unwrap();
507 assert_eq!(v1, v2);
508 }
509
510 #[tokio::test]
511 async fn mock_embed_different_inputs_differ() {
512 let provider = MockEmbeddingProvider::new(32);
513 let v1 = provider.embed("alpha").await.unwrap();
514 let v2 = provider.embed("beta").await.unwrap();
515 assert_ne!(v1, v2);
516 }
517
518 #[tokio::test]
519 async fn mock_embed_batch() {
520 let provider = MockEmbeddingProvider::new(16);
521 let results = provider.embed_batch(&["a", "b", "c"]).await.unwrap();
522 assert_eq!(results.len(), 3);
523 for v in &results {
524 assert_eq!(v.len(), 16);
525 }
526 }
527
528 #[tokio::test]
529 async fn mock_embed_batch_matches_individual() {
530 let provider = MockEmbeddingProvider::new(8);
531 let batch = provider.embed_batch(&["x", "y"]).await.unwrap();
532 let x = provider.embed("x").await.unwrap();
533 let y = provider.embed("y").await.unwrap();
534 assert_eq!(batch[0], x);
535 assert_eq!(batch[1], y);
536 }
537
538 #[test]
539 fn mock_model_name() {
540 let provider = MockEmbeddingProvider::new(16);
541 assert_eq!(provider.model_name(), "mock-sha256");
542 }
543
544 #[test]
545 fn mock_dimensions() {
546 let provider = MockEmbeddingProvider::new(128);
547 assert_eq!(provider.dimensions(), 128);
548 }
549
550 #[test]
551 fn embedding_error_display() {
552 let err = EmbeddingError::DimensionMismatch {
553 expected: 384,
554 got: 256,
555 };
556 assert!(err.to_string().contains("384"));
557 assert!(err.to_string().contains("256"));
558
559 let err2 = EmbeddingError::ModelNotLoaded;
560 assert!(err2.to_string().contains("not loaded"));
561 }
562
563 #[tokio::test]
566 async fn llm_provider_falls_back_to_mock_when_api_unavailable() {
567 let config = LlmEmbeddingConfig {
568 api_available: false,
569 dimensions: 64,
570 ..Default::default()
571 };
572 let provider = LlmEmbeddingProvider::new(config);
573 let vec = provider.embed("hello world").await.unwrap();
575 assert_eq!(vec.len(), 64);
576 }
577
578 #[tokio::test]
579 async fn llm_provider_fallback_is_deterministic() {
580 let config = LlmEmbeddingConfig {
581 api_available: false,
582 dimensions: 32,
583 ..Default::default()
584 };
585 let provider = LlmEmbeddingProvider::new(config);
586 let v1 = provider.embed("test").await.unwrap();
587 let v2 = provider.embed("test").await.unwrap();
588 assert_eq!(v1, v2);
589 }
590
591 #[tokio::test]
592 async fn llm_provider_batch_fallback() {
593 let config = LlmEmbeddingConfig {
594 api_available: false,
595 dimensions: 16,
596 ..Default::default()
597 };
598 let provider = LlmEmbeddingProvider::new(config);
599 let results = provider.embed_batch(&["a", "b", "c"]).await.unwrap();
600 assert_eq!(results.len(), 3);
601 for v in &results {
602 assert_eq!(v.len(), 16);
603 }
604 }
605
606 #[test]
607 fn llm_provider_reports_model_name() {
608 let config = LlmEmbeddingConfig {
609 model: "custom-embed-v1".to_string(),
610 ..Default::default()
611 };
612 let provider = LlmEmbeddingProvider::new(config);
613 assert_eq!(provider.model_name(), "custom-embed-v1");
614 }
615
616 #[test]
617 fn llm_provider_reports_dimensions() {
618 let config = LlmEmbeddingConfig {
619 dimensions: 1536,
620 ..Default::default()
621 };
622 let provider = LlmEmbeddingProvider::new(config);
623 assert_eq!(provider.dimensions(), 1536);
624 }
625
626 #[test]
627 fn llm_provider_api_availability_check() {
628 let unavailable = LlmEmbeddingProvider::new(LlmEmbeddingConfig::default());
629 assert!(!unavailable.is_api_available());
630
631 let available = LlmEmbeddingProvider::new(LlmEmbeddingConfig {
632 api_available: true,
633 ..Default::default()
634 });
635 assert!(available.is_api_available());
636 }
637
638 #[test]
639 fn llm_provider_from_config_defaults() {
640 let table = std::collections::HashMap::new();
641 let provider = LlmEmbeddingProvider::from_config(&table);
642 assert_eq!(provider.dimensions(), 384);
643 assert_eq!(provider.model_name(), "text-embedding-3-small");
644 assert!(!provider.is_api_available());
645 }
646
647 #[test]
648 fn llm_provider_from_config_custom() {
649 let mut table = std::collections::HashMap::new();
650 table.insert("model".to_string(), "my-model".to_string());
651 table.insert("dimensions".to_string(), "768".to_string());
652 table.insert("batch_size".to_string(), "32".to_string());
653 table.insert("api_available".to_string(), "true".to_string());
654 let provider = LlmEmbeddingProvider::from_config(&table);
655 assert_eq!(provider.model_name(), "my-model");
656 assert_eq!(provider.dimensions(), 768);
657 assert_eq!(provider.config().batch_size, 32);
658 assert!(provider.is_api_available());
659 }
660
661 #[test]
662 fn select_provider_returns_mock_when_no_config() {
663 let provider = select_embedding_provider(None);
664 assert_eq!(provider.dimensions(), 64);
665 assert_eq!(provider.model_name(), "mock-sha256");
666 }
667
668 #[test]
669 fn qwen3_search_dirs_include_project_local_bundle() {
670 let dirs = qwen3_model_search_dirs();
671 assert!(
672 dirs.iter()
673 .any(|d| d.ends_with(".weftos/models/Qwen3-Embedding-0.6B")),
674 "expected project-local Qwen3 bundle dir, got: {dirs:?}"
675 );
676 }
677
678 #[test]
679 fn e5_search_paths_include_project_local_model() {
680 let paths = e5_model_search_paths();
681 assert!(
682 paths
683 .iter()
684 .any(|p| p.ends_with(".weftos/models/e5-small-v2/model.onnx")),
685 "expected project-local e5 model path, got: {paths:?}"
686 );
687 }
688
689 #[test]
690 fn select_provider_priorities_skip_unavailable_qwen3() {
691 let provider = select_embedding_provider(None);
694 assert_eq!(provider.model_name(), "mock-sha256");
695 }
696
697 #[tokio::test]
698 async fn mock_embed_query_defaults_to_embed() {
699 let provider = MockEmbeddingProvider::new(16);
700 let a = provider.embed("hello").await.unwrap();
701 let b = provider.embed_query("hello").await.unwrap();
702 assert_eq!(a, b);
703 }
704
705 #[test]
706 fn select_provider_returns_llm_when_config_present() {
707 let config = LlmEmbeddingConfig {
708 model: "test-embed".to_string(),
709 dimensions: 256,
710 ..Default::default()
711 };
712 let provider = select_embedding_provider(Some(config));
713 assert_eq!(provider.dimensions(), 256);
714 assert_eq!(provider.model_name(), "test-embed");
715 }
716
717 #[tokio::test]
718 async fn llm_provider_fallback_matches_mock() {
719 let config = LlmEmbeddingConfig {
720 api_available: false,
721 dimensions: 32,
722 ..Default::default()
723 };
724 let llm = LlmEmbeddingProvider::new(config);
725 let mock = MockEmbeddingProvider::new(32);
726 let llm_vec = llm.embed("same input").await.unwrap();
727 let mock_vec = mock.embed("same input").await.unwrap();
728 assert_eq!(llm_vec, mock_vec);
730 }
731}