use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::{Error, LoadedModel};
#[cfg(feature = "progress")]
use indicatif::{ProgressBar, ProgressStyle};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConversionFormat {
SafeTensors,
#[cfg(feature = "onnx")]
ONNX,
#[cfg(feature = "gguf")]
GGUF,
#[cfg(feature = "pytorch")]
PyTorch,
#[cfg(feature = "awq")]
AWQ,
}
#[derive(Debug, Clone, Default)]
pub struct ConversionOptions {
pub target_format: ConversionFormat,
pub preserve_metadata: bool,
pub quantization: Option<String>,
pub max_memory_usage: usize,
pub worker_threads: usize,
pub validate_output: bool,
pub custom_metadata: HashMap<String, String>,
}
impl Default for ConversionFormat {
fn default() -> Self {
Self::SafeTensors
}
}
#[derive(Debug, Clone)]
pub struct ConversionJob {
pub source: PathBuf,
pub target: PathBuf,
pub options: ConversionOptions,
}
#[derive(Debug)]
pub struct ConversionResult {
pub success: bool,
pub source_path: PathBuf,
pub target_path: PathBuf,
pub source_format: String,
pub target_format: ConversionFormat,
pub tensors_converted: usize,
pub bytes_processed: usize,
pub duration_ms: u64,
pub warnings: Vec<String>,
pub error: Option<String>,
}
pub fn convert_model<P: AsRef<Path>>(
source_path: P,
target_path: P,
options: ConversionOptions,
) -> Result<ConversionResult, Error> {
let start_time = std::time::Instant::now();
let source_path = source_path.as_ref().to_path_buf();
let target_path = target_path.as_ref().to_path_buf();
let source_format = detect_format(&source_path)?;
#[cfg(feature = "progress")]
#[cfg(feature = "progress")]
let progress = Some(create_progress_bar("Converting model"));
#[cfg(not(feature = "progress"))]
let progress: Option<()> = None;
let result = Err(Error::other(format!(
"Model conversion from {} to {:?} is not yet implemented. \
The conversion API framework is ready but individual format conversions \
need to be completed. Source: {}, Target: {}",
source_format,
options.target_format,
source_path.display(),
target_path.display()
)));
#[cfg(feature = "progress")]
if let Some(pb) = progress {
pb.finish_with_message("Conversion complete");
}
let duration_ms = start_time.elapsed().as_millis() as u64;
match result {
Ok((tensors_converted, bytes_processed, warnings)) => Ok(ConversionResult {
success: true,
source_path,
target_path,
source_format,
target_format: options.target_format,
tensors_converted,
bytes_processed,
duration_ms,
warnings,
error: None,
}),
Err(e) => Ok(ConversionResult {
success: false,
source_path,
target_path,
source_format,
target_format: options.target_format,
tensors_converted: 0,
bytes_processed: 0,
duration_ms,
warnings: Vec::new(),
error: Some(e.to_string()),
}),
}
}
pub fn convert_batch(
jobs: Vec<ConversionJob>,
parallel: bool,
) -> Result<Vec<ConversionResult>, Error> {
if parallel {
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
Ok(jobs
.into_par_iter()
.map(|job| convert_model(job.source, job.target, job.options))
.map(|r| {
r.unwrap_or_else(|e| ConversionResult {
success: false,
error: Some(e.to_string()),
..Default::default()
})
})
.collect())
}
#[cfg(not(feature = "rayon"))]
{
convert_batch(jobs, false)
}
} else {
let mut results = Vec::new();
for job in jobs {
let result = convert_model(job.source, job.target, job.options).unwrap_or_else(|e| {
ConversionResult {
success: false,
error: Some(e.to_string()),
..Default::default()
}
});
results.push(result);
}
Ok(results)
}
}
fn detect_format(path: &Path) -> Result<String, Error> {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
match extension.to_lowercase().as_str() {
"safetensors" => Ok("SafeTensors".to_string()),
#[cfg(feature = "onnx")]
"onnx" => Ok("ONNX".to_string()),
#[cfg(feature = "gguf")]
"gguf" => Ok("GGUF".to_string()),
#[cfg(feature = "pytorch")]
"pth" | "pt" | "bin" => Ok("PyTorch".to_string()),
#[cfg(feature = "awq")]
"awq" => Ok("AWQ".to_string()),
_ => {
detect_format_by_content(path)
}
}
}
fn detect_format_by_content(path: &Path) -> Result<String, Error> {
use std::fs::File;
use std::io::Read;
let mut file = File::open(path)
.map_err(|e| Error::io_error(format!("Failed to open {}: {}", path.display(), e)))?;
let mut header = [0u8; 16];
file.read_exact(&mut header)
.map_err(|e| Error::io_error(format!("Failed to read file header: {}", e)))?;
if header.starts_with(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) {
return Ok("SafeTensors".to_string());
}
#[cfg(feature = "gguf")]
if header.starts_with(b"GGUF") {
return Ok("GGUF".to_string());
}
#[cfg(feature = "onnx")]
if header.starts_with(&[0x08, 0x01]) || header.starts_with(&[0x08, 0x07]) {
return Ok("ONNX".to_string());
}
#[cfg(feature = "pytorch")]
if header.starts_with(&[0x80, 0x02]) || header.starts_with(b"PK") {
return Ok("PyTorch".to_string());
}
Err(Error::unsupported_format(format!(
"Could not determine format of file: {}",
path.display()
)))
}
fn copy_with_metadata(
source_path: &Path,
target_path: &Path,
options: &ConversionOptions,
) -> Result<(usize, usize, Vec<String>), Error> {
use std::fs;
if options.custom_metadata.is_empty() && options.preserve_metadata {
fs::copy(source_path, target_path)
.map_err(|e| Error::io_error(format!("Failed to copy file: {}", e)))?;
let file_size = fs::metadata(target_path)
.map_err(|e| Error::io_error(format!("Failed to get file metadata: {}", e)))?
.len() as usize;
Ok((1, file_size, Vec::new()))
} else {
convert_between_formats(
source_path,
target_path,
&detect_format(source_path)?,
options,
)
}
}
fn convert_between_formats(
source_path: &Path,
target_path: &Path,
source_format: &str,
options: &ConversionOptions,
) -> Result<(usize, usize, Vec<String>), Error> {
let loaded_model = load_model_by_format(source_path, source_format)?;
let mut warnings = Vec::new();
let tensors_converted = loaded_model.raw_tensors.len();
let bytes_processed = calculate_total_size(&loaded_model.raw_tensors);
save_model_by_format(target_path, &loaded_model, &options.target_format, options)?;
if source_format == "PyTorch" && options.target_format == ConversionFormat::ONNX {
warnings.push("PyTorch to ONNX conversion may lose dynamic graph information".to_string());
}
if source_format == "ONNX" && options.target_format == ConversionFormat::GGUF {
if options.quantization.is_some() {
warnings.push(
"Quantization during ONNX to GGUF conversion may affect model accuracy".to_string(),
);
}
}
Ok((tensors_converted, bytes_processed, warnings))
}
fn load_model_by_format(path: &Path, _format: &str) -> Result<LoadedModel, Error> {
let options = crate::LoadOptions::default();
crate::universal_loader::load_model(path, options)
}
fn save_model_by_format(
path: &Path,
model: &LoadedModel,
format: &ConversionFormat,
options: &ConversionOptions,
) -> Result<(), Error> {
match format {
ConversionFormat::SafeTensors => crate::formats::safetensors_export::save_as_safetensors(
model,
path,
options.preserve_metadata,
),
#[cfg(feature = "onnx")]
ConversionFormat::ONNX => {
let export_options = crate::formats::onnx_export::OnnxExportOptions {
preserve_metadata: options.preserve_metadata,
custom_metadata: options.custom_metadata.clone(),
..Default::default()
};
crate::formats::onnx_export::save_as_onnx(model, path, export_options)
}
#[cfg(feature = "gguf")]
ConversionFormat::GGUF => {
let export_options = crate::formats::gguf_export::GgufExportOptions {
quantization: options.quantization.clone(),
preserve_metadata: options.preserve_metadata,
custom_metadata: options.custom_metadata.clone(),
..Default::default()
};
crate::formats::gguf_export::save_as_gguf(model, path, export_options)
}
#[cfg(feature = "pytorch")]
ConversionFormat::PyTorch => {
crate::formats::pytorch_export::save_as_pytorch(model, path, options.preserve_metadata)
}
#[cfg(feature = "awq")]
ConversionFormat::AWQ => {
let export_options = crate::formats::awq_export::AwqExportOptions {
quantization: options.quantization.clone(),
preserve_metadata: options.preserve_metadata,
..Default::default()
};
crate::formats::awq_export::save_as_awq(model, path, export_options)
}
}
}
fn calculate_total_size(tensors: &HashMap<String, candle_core::Tensor>) -> usize {
tensors
.values()
.map(|tensor| {
let elem_count = tensor.shape().elem_count();
let dtype_size = match tensor.dtype() {
candle_core::DType::U8 => 1,
candle_core::DType::U32 => 4,
candle_core::DType::I64 => 8,
candle_core::DType::BF16 | candle_core::DType::F16 => 2,
candle_core::DType::F32 => 4,
candle_core::DType::F64 => 8,
};
elem_count * dtype_size
})
.sum()
}
#[cfg(feature = "progress")]
fn create_progress_bar(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}: {elapsed}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(100));
pb
}
impl Default for ConversionResult {
fn default() -> Self {
Self {
success: false,
source_path: PathBuf::new(),
target_path: PathBuf::new(),
source_format: "Unknown".to_string(),
target_format: ConversionFormat::SafeTensors,
tensors_converted: 0,
bytes_processed: 0,
duration_ms: 0,
warnings: Vec::new(),
error: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_format_detection() {
assert_eq!(
detect_format(Path::new("model.safetensors")).unwrap(),
"SafeTensors"
);
#[cfg(feature = "onnx")]
assert_eq!(detect_format(Path::new("model.onnx")).unwrap(), "ONNX");
}
#[test]
fn test_conversion_options_default() {
let options = ConversionOptions::default();
assert_eq!(options.target_format, ConversionFormat::SafeTensors);
assert!(!options.preserve_metadata);
assert!(options.quantization.is_none());
assert_eq!(options.max_memory_usage, 0);
}
#[test]
fn test_conversion_result_default() {
let result = ConversionResult::default();
assert!(!result.success);
assert_eq!(result.tensors_converted, 0);
assert!(result.error.is_none());
}
}