use anyhow::Result;
use candle_core::Device;
use candle_transformers::models::distilbert::DistilBertModel;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
use tokenizers::Tokenizer;
use tracing::{debug, info, warn};
use crate::constants;
use crate::error::TurboPropError;
use crate::models::{EmbeddingBackend, EmbeddingModel, ModelInfo};
use crate::types::ModelType;
const GGUF_MAGIC: &[u8] = b"GGUF";
#[derive(Debug, Clone)]
pub struct GGUFConfig {
pub device: GGUFDevice,
pub memory_limit_bytes: Option<u64>,
pub context_length: usize,
pub enable_batching: bool,
pub gpu_layers: u32,
pub cpu_threads: Option<usize>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GGUFDevice {
Cpu,
Gpu,
Cuda,
Metal,
}
impl Default for GGUFConfig {
fn default() -> Self {
Self {
device: GGUFDevice::Cpu,
memory_limit_bytes: None,
context_length: constants::config::DEFAULT_CONTEXT_LENGTH,
enable_batching: true,
gpu_layers: constants::config::DEFAULT_GPU_LAYERS,
cpu_threads: Some(constants::config::DEFAULT_CPU_THREADS as usize),
}
}
}
impl GGUFConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_device(mut self, device: GGUFDevice) -> Self {
self.device = device;
self
}
pub fn with_memory_limit(mut self, limit_bytes: u64) -> Self {
self.memory_limit_bytes = Some(limit_bytes);
self
}
pub fn with_context_length(mut self, length: usize) -> Self {
self.context_length = length;
self
}
pub fn with_batching(mut self, enable: bool) -> Self {
self.enable_batching = enable;
self
}
pub fn with_gpu_layers(mut self, layers: u32) -> Self {
self.gpu_layers = layers;
self
}
pub fn with_cpu_threads(mut self, threads: usize) -> Self {
self.cpu_threads = Some(threads);
self
}
pub fn parse_memory_limit(limit_str: &str) -> Result<u64> {
let limit_str = limit_str.trim().to_uppercase();
if let Some(stripped) = limit_str.strip_suffix("GB") {
let gigabytes: f64 = stripped.parse().map_err(|_| {
TurboPropError::config_validation(
"memory_limit",
&limit_str,
"Valid format like '2GB', '1.5GB'",
)
})?;
Ok((gigabytes * 1024.0 * 1024.0 * 1024.0) as u64)
} else if let Some(stripped) = limit_str.strip_suffix("MB") {
let megabytes: f64 = stripped.parse().map_err(|_| {
TurboPropError::config_validation(
"memory_limit",
&limit_str,
"Valid format like '512MB', '1024MB'",
)
})?;
Ok((megabytes * 1024.0 * 1024.0) as u64)
} else if let Some(stripped) = limit_str.strip_suffix("B") {
let bytes: u64 = stripped.parse().map_err(|_| {
TurboPropError::config_validation(
"memory_limit",
&limit_str,
"Valid format like '1024B'",
)
})?;
Ok(bytes)
} else {
Err(TurboPropError::config_validation(
"memory_limit",
&limit_str,
"Valid format like '2GB', '512MB', '1024B'",
)
.into())
}
}
pub fn parse_device(device_str: &str) -> Result<GGUFDevice> {
match device_str.to_lowercase().as_str() {
"cpu" => Ok(GGUFDevice::Cpu),
"gpu" => Ok(GGUFDevice::Gpu),
"cuda" => Ok(GGUFDevice::Cuda),
"metal" => Ok(GGUFDevice::Metal),
_ => Err(TurboPropError::config_validation(
"device",
device_str,
"One of: 'cpu', 'gpu', 'cuda', 'metal'",
)
.into()),
}
}
}
pub fn validate_gguf_file(path: &Path) -> Result<()> {
let model_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
if !path.exists() {
return Err(TurboPropError::gguf_format(model_name, "File does not exist").into());
}
if path.extension().is_none_or(|ext| ext != "gguf") {
return Err(
TurboPropError::gguf_format(model_name, "File does not have .gguf extension").into(),
);
}
let metadata = std::fs::metadata(path).map_err(|e| {
TurboPropError::gguf_format(model_name, format!("Cannot read file metadata: {}", e))
})?;
if metadata.len() < constants::gguf::MINIMUM_FILE_SIZE_BYTES {
return Err(TurboPropError::gguf_format(
model_name,
"File is too small to be a valid GGUF model",
)
.into());
}
let file = File::open(path).map_err(|e| {
TurboPropError::gguf_format(model_name, format!("Cannot open file for reading: {}", e))
})?;
let mut reader = BufReader::new(file);
let mut magic_buf = [0u8; constants::gguf::MAGIC_HEADER_SIZE_BYTES];
reader.read_exact(&mut magic_buf).map_err(|e| {
TurboPropError::gguf_format(model_name, format!("Cannot read GGUF magic header: {}", e))
})?;
if magic_buf != GGUF_MAGIC {
return Err(TurboPropError::gguf_format(
model_name,
format!(
"Invalid GGUF magic header. Expected 'GGUF', found: {:?}",
std::str::from_utf8(&magic_buf).unwrap_or("<invalid utf8>")
),
)
.into());
}
let mut version_buf = [0u8; constants::gguf::VERSION_FIELD_SIZE_BYTES];
reader.read_exact(&mut version_buf).map_err(|e| {
TurboPropError::gguf_format(model_name, format!("Cannot read GGUF version: {}", e))
})?;
let version = u32::from_le_bytes(version_buf);
if !(1..=3).contains(&version) {
return Err(TurboPropError::gguf_format(
model_name,
format!(
"Unsupported GGUF version: {}. Supported versions: 1-3",
version
),
)
.into());
}
info!(
"GGUF file validation passed: {} (version {})",
path.display(),
version
);
Ok(())
}
pub struct GGUFBackend {
device: Device,
config: GGUFConfig,
}
impl GGUFBackend {
pub fn new() -> Result<Self> {
Self::new_with_config(GGUFConfig::default())
}
pub fn new_with_config(config: GGUFConfig) -> Result<Self> {
let device = match config.device {
GGUFDevice::Cpu => Device::Cpu,
GGUFDevice::Gpu | GGUFDevice::Cuda => {
warn!("GPU device requested but not yet implemented, falling back to CPU");
Device::Cpu
}
GGUFDevice::Metal => {
warn!("Metal device requested but not yet implemented, falling back to CPU");
Device::Cpu
}
};
debug!(
"Initialized GGUF backend with device: {:?}, config: {:?}",
device, config
);
Ok(Self { device, config })
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn config(&self) -> &GGUFConfig {
&self.config
}
}
impl Default for GGUFBackend {
fn default() -> Self {
Self::new().expect("Failed to create default GGUF backend")
}
}
impl EmbeddingBackend for GGUFBackend {
fn load_model(&self, model_info: &ModelInfo) -> Result<Box<dyn EmbeddingModel>> {
if !self.supports_model(&model_info.model_type) {
return Err(TurboPropError::gguf_model_load(
model_info.name.as_str(),
format!(
"GGUF backend does not support model type: {:?}",
model_info.model_type
),
)
.into());
}
info!("Loading GGUF model: {}", model_info.name);
let model = if let Some(local_path) = &model_info.local_path {
info!(
"Loading GGUF model from local path: {}",
local_path.display()
);
GGUFEmbeddingModel::load_from_path_with_config(
local_path,
model_info,
self.config.clone(),
)?
} else {
info!("Creating GGUF model instance (model will be loaded from download)");
GGUFEmbeddingModel::new_with_config(
model_info.name.to_string(),
model_info.dimensions,
self.device.clone(),
self.config.clone(),
)?
};
Ok(Box::new(model))
}
fn supports_model(&self, model_type: &ModelType) -> bool {
matches!(model_type, ModelType::GGUF)
}
}
pub struct GGUFEmbeddingModel {
model_name: String,
dimensions: usize,
device: Device,
model: Option<DistilBertModel>,
tokenizer: Option<Tokenizer>,
max_sequence_length: usize,
config: GGUFConfig,
}
impl GGUFEmbeddingModel {
pub fn new(model_name: String, dimensions: usize, device: Device) -> Result<Self> {
Self::new_with_config(model_name, dimensions, device, GGUFConfig::default())
}
fn validate_embedding_inputs(&self, texts: &[String]) -> Result<()> {
if texts.is_empty() {
return Ok(());
}
for (i, text) in texts.iter().enumerate() {
if text.is_empty() {
return Err(TurboPropError::gguf_inference(
&self.model_name,
format!("Empty text found at index {}", i),
)
.into());
}
if text.len() > self.max_sequence_length * constants::text::CHARS_PER_TOKEN_ESTIMATE {
return Err(TurboPropError::gguf_inference(
&self.model_name,
format!(
"Text at index {} is too long ({} chars). Maximum estimated length: {} chars",
i, text.len(), self.max_sequence_length * constants::text::CHARS_PER_TOKEN_ESTIMATE
),
)
.into());
}
}
Ok(())
}
pub fn new_with_config(
model_name: String,
dimensions: usize,
device: Device,
config: GGUFConfig,
) -> Result<Self> {
info!(
"Creating GGUF embedding model: {} with config: {:?}",
model_name, config
);
Ok(Self {
model_name,
dimensions,
device,
model: None,
tokenizer: None,
max_sequence_length: config.context_length,
config,
})
}
pub fn load_from_path(model_path: &Path, model_info: &ModelInfo) -> Result<Self> {
Self::load_from_path_with_config(model_path, model_info, GGUFConfig::default())
}
pub fn load_from_path_with_config(
model_path: &Path,
model_info: &ModelInfo,
config: GGUFConfig,
) -> Result<Self> {
let model_name = model_info.name.clone();
info!(
"Loading GGUF model from path: {} with config: {:?}",
model_path.display(),
config
);
validate_gguf_file(model_path)?;
let model = Self::new_with_config(
model_name.to_string(),
model_info.dimensions,
Device::Cpu,
config,
)?;
info!("GGUF model structure created (actual loading not yet implemented)");
Ok(model)
}
pub fn load_from_path_legacy(model_path: &Path) -> Result<Self> {
let model_name = model_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let default_dimensions = 768;
use crate::models::ModelInfoConfig;
use crate::types::{ModelBackend, ModelName};
let model_info = ModelInfo::new(ModelInfoConfig {
name: ModelName::from(model_name),
description: "Legacy loaded GGUF model".to_string(),
dimensions: default_dimensions,
size_bytes: 0, model_type: ModelType::GGUF,
backend: ModelBackend::Candle,
download_url: None,
local_path: Some(model_path.to_path_buf()),
});
Self::load_from_path(model_path, &model_info)
}
pub fn load_tokenizer(&mut self, tokenizer_path: &Path) -> Result<()> {
info!("Loading tokenizer from: {}", tokenizer_path.display());
if !tokenizer_path.exists() {
return Err(TurboPropError::gguf_model_load(
&self.model_name,
format!(
"Tokenizer file not found at path: {}",
tokenizer_path.display()
),
)
.into());
}
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
TurboPropError::gguf_model_load(
&self.model_name,
format!("Failed to load tokenizer: {}", e),
)
})?;
self.tokenizer = Some(tokenizer);
info!("Tokenizer loaded successfully");
Ok(())
}
pub fn config(&self) -> &GGUFConfig {
&self.config
}
}
impl EmbeddingModel for GGUFEmbeddingModel {
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
debug!(
"Generating embeddings for {} texts using GGUF model",
texts.len()
);
self.validate_embedding_inputs(texts)?;
if texts.is_empty() {
return Ok(Vec::new());
}
if self.model.is_none() {
warn!("GGUF model not loaded, using placeholder embeddings");
}
if self.tokenizer.is_none() {
warn!("Tokenizer not loaded, using placeholder embeddings");
}
let mut embeddings = Vec::new();
for text in texts {
debug!(
"Processing text: {}",
text.chars()
.take(constants::text::ERROR_MESSAGE_TEXT_PREVIEW_LENGTH)
.collect::<String>()
);
let text_hash = text.len() % constants::test::TEXT_HASH_MODULO;
let base_value = constants::test::TEST_EMBEDDING_BASE_VALUE
+ (text_hash as f32) * constants::test::TEST_EMBEDDING_VARIATION_FACTOR;
let embedding = vec![base_value; self.dimensions];
embeddings.push(embedding);
}
info!(
"Generated {} embeddings with {} dimensions",
embeddings.len(),
self.dimensions
);
Ok(embeddings)
}
fn dimensions(&self) -> usize {
self.dimensions
}
fn max_sequence_length(&self) -> usize {
self.max_sequence_length
}
}
impl std::fmt::Debug for GGUFEmbeddingModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GGUFEmbeddingModel")
.field("model_name", &self.model_name)
.field("dimensions", &self.dimensions)
.field("device", &self.device)
.field("model_loaded", &self.model.is_some())
.field("tokenizer_loaded", &self.tokenizer.is_some())
.field("max_sequence_length", &self.max_sequence_length)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ModelInfo, ModelInfoConfig};
use crate::types::{ModelBackend, ModelName, ModelType};
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_gguf_backend_creation() {
let backend = GGUFBackend::new();
assert!(backend.is_ok());
let backend = backend.unwrap();
assert!(matches!(backend.device(), Device::Cpu));
}
#[test]
fn test_gguf_backend_supports_model() {
let backend = GGUFBackend::new().unwrap();
assert!(backend.supports_model(&ModelType::GGUF));
assert!(!backend.supports_model(&ModelType::SentenceTransformer));
assert!(!backend.supports_model(&ModelType::HuggingFace));
}
#[test]
fn test_gguf_backend_load_model_success() {
let backend = GGUFBackend::new().unwrap();
let model_info = ModelInfo::new(ModelInfoConfig {
name: ModelName::from("nomic-embed-code.Q5_K_S.gguf"),
description: "Test GGUF model".to_string(),
dimensions: 768,
size_bytes: constants::test::DEFAULT_MODEL_SIZE_BYTES,
model_type: ModelType::GGUF,
backend: ModelBackend::Candle,
download_url: None,
local_path: None,
});
let result = backend.load_model(&model_info);
assert!(result.is_ok());
let model = result.unwrap();
assert_eq!(model.dimensions(), 768);
assert_eq!(model.max_sequence_length(), 256);
}
#[test]
fn test_gguf_backend_load_model_unsupported() {
let backend = GGUFBackend::new().unwrap();
let model_info = ModelInfo::new(ModelInfoConfig {
name: ModelName::from("sentence-transformer"),
description: "Test model".to_string(),
dimensions: 384,
size_bytes: constants::test::DEFAULT_MODEL_SIZE_BYTES,
model_type: ModelType::SentenceTransformer,
backend: ModelBackend::FastEmbed,
download_url: None,
local_path: None,
});
let result = backend.load_model(&model_info);
assert!(result.is_err());
let error_message = result.err().unwrap().to_string();
assert!(error_message.contains("does not support model type"));
}
#[test]
fn test_gguf_embedding_model_creation() {
let model = GGUFEmbeddingModel::new("test-model".to_string(), 768, Device::Cpu);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!(model.dimensions(), 768);
assert_eq!(model.max_sequence_length(), 256);
}
#[test]
fn test_gguf_embedding_model_embed_single() {
let model = GGUFEmbeddingModel::new("test-model".to_string(), 768, Device::Cpu).unwrap();
let texts = vec!["Hello, world!".to_string()];
let result = model.embed(&texts);
assert!(result.is_ok());
let embeddings = result.unwrap();
assert_eq!(embeddings.len(), 1);
assert_eq!(embeddings[0].len(), 768);
}
#[test]
fn test_gguf_embedding_model_embed_batch() {
let model = GGUFEmbeddingModel::new("test-model".to_string(), 768, Device::Cpu).unwrap();
let texts = vec![
"First text".to_string(),
"Second text".to_string(),
"Third text".to_string(),
];
let result = model.embed(&texts);
assert!(result.is_ok());
let embeddings = result.unwrap();
assert_eq!(embeddings.len(), 3);
for embedding in &embeddings {
assert_eq!(embedding.len(), 768);
}
}
#[test]
fn test_gguf_embedding_model_embed_empty() {
let model = GGUFEmbeddingModel::new("test-model".to_string(), 768, Device::Cpu).unwrap();
let texts: Vec<String> = vec![];
let result = model.embed(&texts);
assert!(result.is_ok());
let embeddings = result.unwrap();
assert_eq!(embeddings.len(), 0);
}
#[test]
fn test_validate_gguf_file_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let fake_path = temp_dir.path().join("nonexistent.gguf");
let result = validate_gguf_file(&fake_path);
assert!(result.is_err());
let error_msg = result.err().unwrap().to_string();
assert!(error_msg.contains("File does not exist"));
}
#[test]
fn test_validate_gguf_file_wrong_extension() {
let temp_dir = TempDir::new().unwrap();
let wrong_ext_path = temp_dir.path().join("model.bin");
File::create(&wrong_ext_path).unwrap();
let result = validate_gguf_file(&wrong_ext_path);
assert!(result.is_err());
let error_msg = result.err().unwrap().to_string();
assert!(error_msg.contains("does not have .gguf extension"));
}
#[test]
fn test_validate_gguf_file_too_small() {
let temp_dir = TempDir::new().unwrap();
let small_file_path = temp_dir.path().join("small.gguf");
let mut file = File::create(&small_file_path).unwrap();
file.write_all(b"GGUF").unwrap();
let result = validate_gguf_file(&small_file_path);
assert!(result.is_err());
let error_msg = result.err().unwrap().to_string();
assert!(error_msg.contains("too small to be a valid GGUF model"));
}
#[test]
fn test_validate_gguf_file_invalid_magic() {
let temp_dir = TempDir::new().unwrap();
let invalid_magic_path = temp_dir.path().join("invalid.gguf");
let mut file = File::create(&invalid_magic_path).unwrap();
file.write_all(b"FAKE").unwrap(); file.write_all(&[1, 0, 0, 0]).unwrap(); file.write_all(&[0, 0, 0, 0]).unwrap();
let result = validate_gguf_file(&invalid_magic_path);
assert!(result.is_err());
let error_msg = result.err().unwrap().to_string();
assert!(error_msg.contains("Invalid GGUF magic header"));
}
#[test]
fn test_validate_gguf_file_unsupported_version() {
let temp_dir = TempDir::new().unwrap();
let unsupported_version_path = temp_dir.path().join("unsupported.gguf");
let mut file = File::create(&unsupported_version_path).unwrap();
file.write_all(b"GGUF").unwrap(); file.write_all(&[99, 0, 0, 0]).unwrap(); file.write_all(&[0, 0, 0, 0]).unwrap();
let result = validate_gguf_file(&unsupported_version_path);
assert!(result.is_err());
let error_msg = result.err().unwrap().to_string();
assert!(error_msg.contains("Unsupported GGUF version: 99"));
}
#[test]
fn test_validate_gguf_file_valid() {
let temp_dir = TempDir::new().unwrap();
let valid_gguf_path = temp_dir.path().join("valid.gguf");
let mut file = File::create(&valid_gguf_path).unwrap();
file.write_all(b"GGUF").unwrap(); file.write_all(&[2, 0, 0, 0]).unwrap(); file.write_all(&[0, 0, 0, 0]).unwrap();
let result = validate_gguf_file(&valid_gguf_path);
assert!(result.is_ok());
}
#[test]
fn test_gguf_config_default() {
let config = GGUFConfig::default();
assert_eq!(config.device, GGUFDevice::Cpu);
assert_eq!(config.context_length, 256);
assert!(config.enable_batching);
assert_eq!(config.gpu_layers, 32);
assert!(config.memory_limit_bytes.is_none());
assert_eq!(config.cpu_threads, Some(8));
}
#[test]
fn test_gguf_config_builder() {
let config = GGUFConfig::new()
.with_device(GGUFDevice::Cuda)
.with_memory_limit(2048 * 1024 * 1024) .with_context_length(1024)
.with_batching(false)
.with_gpu_layers(32)
.with_cpu_threads(8);
assert_eq!(config.device, GGUFDevice::Cuda);
assert_eq!(config.memory_limit_bytes, Some(2048 * 1024 * 1024));
assert_eq!(config.context_length, 1024);
assert!(!config.enable_batching);
assert_eq!(config.gpu_layers, 32);
assert_eq!(config.cpu_threads, Some(8));
}
#[test]
fn test_gguf_config_parse_memory_limit() {
assert_eq!(
GGUFConfig::parse_memory_limit("2GB").unwrap(),
2 * 1024 * 1024 * 1024
);
assert_eq!(
GGUFConfig::parse_memory_limit("512MB").unwrap(),
512 * 1024 * 1024
);
assert_eq!(GGUFConfig::parse_memory_limit("1024B").unwrap(), 1024);
assert_eq!(
GGUFConfig::parse_memory_limit("1.5GB").unwrap(),
(1.5 * 1024.0 * 1024.0 * 1024.0) as u64
);
assert!(GGUFConfig::parse_memory_limit("invalid").is_err());
assert!(GGUFConfig::parse_memory_limit("2TB").is_err()); }
#[test]
fn test_gguf_config_parse_device() {
assert_eq!(GGUFConfig::parse_device("cpu").unwrap(), GGUFDevice::Cpu);
assert_eq!(GGUFConfig::parse_device("GPU").unwrap(), GGUFDevice::Gpu);
assert_eq!(GGUFConfig::parse_device("cuda").unwrap(), GGUFDevice::Cuda);
assert_eq!(
GGUFConfig::parse_device("METAL").unwrap(),
GGUFDevice::Metal
);
assert!(GGUFConfig::parse_device("invalid").is_err());
}
#[test]
fn test_gguf_backend_with_config() {
let config = GGUFConfig::new()
.with_device(GGUFDevice::Cpu)
.with_context_length(256);
let backend = GGUFBackend::new_with_config(config.clone()).unwrap();
assert_eq!(backend.config().device, GGUFDevice::Cpu);
assert_eq!(backend.config().context_length, 256);
}
#[test]
fn test_gguf_embedding_model_with_config() {
let config = GGUFConfig::new()
.with_context_length(1024)
.with_batching(false);
let model =
GGUFEmbeddingModel::new_with_config("test-model".to_string(), 768, Device::Cpu, config)
.unwrap();
assert_eq!(model.max_sequence_length(), 1024); assert!(!model.config.enable_batching);
}
}