use std::path::PathBuf;
use foundation_deployment_huggingface::{HFClient, HFRepository, RepoDownloadFileParams};
use crate::backends::llamacpp::{LlamaBackendConfig, LlamaBackends, LlamaModels};
use crate::errors::{ModelProviderErrors, ModelProviderResult};
use crate::types::base_types::{
MessageType, ModelAPI, ModelId, ModelProvider, ModelProviderDescriptor, ModelProviders,
ModelSpec, ModelUsageCosting,
};
use foundation_deployment_huggingface::repository;
#[derive(Clone)]
pub struct HuggingFaceGGUFProvider {
hf_client: HFClient,
llama_backend: LlamaBackends,
cache_dir: PathBuf,
default_quantization: Option<String>,
llama_config: LlamaBackendConfig,
}
#[derive(Debug)]
pub struct HuggingFaceGGUFConfig {
pub auth: Option<foundation_auth::AuthCredential>,
pub cache_dir: PathBuf,
pub default_quantization: Option<String>,
pub llama_config: LlamaBackendConfig,
pub llama_backend: LlamaBackends,
}
impl Default for HuggingFaceGGUFConfig {
fn default() -> Self {
Self {
auth: None,
cache_dir: default_cache_dir(),
default_quantization: Some("q4_k_m".to_string()),
llama_config: LlamaBackendConfig::default(),
llama_backend: LlamaBackends::LLamaCPU,
}
}
}
impl Clone for HuggingFaceGGUFConfig {
fn clone(&self) -> Self {
Self {
auth: None,
cache_dir: self.cache_dir.clone(),
default_quantization: self.default_quantization.clone(),
llama_config: self.llama_config.clone(),
llama_backend: self.llama_backend,
}
}
}
impl crate::types::base_types::AuthProvider for HuggingFaceGGUFConfig {
fn auth(&self) -> Option<&foundation_auth::AuthCredential> {
self.auth.as_ref()
}
}
impl HuggingFaceGGUFConfig {
#[must_use]
pub fn builder() -> HuggingFaceGGUFConfigBuilder {
HuggingFaceGGUFConfigBuilder::new()
}
}
fn default_cache_dir() -> PathBuf {
std::env::var("HF_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("XDG_CACHE_HOME")
.ok()
.map(|p| PathBuf::from(p).join("huggingface"))
})
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|p| PathBuf::from(p).join(".cache").join("huggingface"))
})
.unwrap_or_else(|| PathBuf::from("./huggingface_cache"))
}
#[derive(Debug, Clone)]
pub struct HuggingFaceGGUFConfigBuilder {
config: HuggingFaceGGUFConfig,
}
impl Default for HuggingFaceGGUFConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl HuggingFaceGGUFConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: HuggingFaceGGUFConfig::default(),
}
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
self.config.auth = Some(foundation_auth::AuthCredential::SecretOnly(
foundation_auth::ConfidentialText::new(token.into()),
));
self
}
#[must_use]
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.config.cache_dir = path.into();
self
}
#[must_use]
pub fn default_quantization(mut self, quant: impl Into<String>) -> Self {
self.config.default_quantization = Some(quant.into());
self
}
#[must_use]
pub fn llama_config(mut self, config: LlamaBackendConfig) -> Self {
self.config.llama_config = config;
self
}
#[must_use]
pub fn n_gpu_layers(mut self, n: u32) -> Self {
self.config.llama_config.n_gpu_layers = n;
self
}
#[must_use]
pub fn n_threads(mut self, n: usize) -> Self {
self.config.llama_config.n_threads = n;
self
}
#[must_use]
pub fn context_length(mut self, n: usize) -> Self {
self.config.llama_config.context_length = n;
self
}
#[must_use]
pub fn llama_backend(mut self, backend: LlamaBackends) -> Self {
self.config.llama_backend = backend;
self
}
#[must_use]
pub fn build(self) -> HuggingFaceGGUFConfig {
self.config
}
}
#[derive(Debug, Clone)]
pub struct ParsedModelId {
pub repo_id: String,
pub quantization: Option<String>,
pub revision: String,
}
impl HuggingFaceGGUFProvider {
pub fn new(config: HuggingFaceGGUFConfig) -> ModelProviderResult<Self> {
let hf_client = HFClient::builder()
.token(
config
.auth
.as_ref()
.map(|a| match a {
foundation_auth::AuthCredential::SecretOnly(t) => t.get(),
_ => String::new(),
})
.unwrap_or_default(),
)
.build()
.map_err(|e| {
ModelProviderErrors::FailedFetching(Box::new(std::io::Error::other(format!(
"Failed to create HFClient: {e}"
))))
})?;
std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
ModelProviderErrors::FailedFetching(Box::new(std::io::Error::other(format!(
"Failed to create cache directory: {e}"
))))
})?;
Ok(Self {
hf_client,
llama_backend: config.llama_backend,
cache_dir: config.cache_dir,
default_quantization: config.default_quantization,
llama_config: config.llama_config,
})
}
#[must_use]
pub fn parse_model_id(&self, model_id: &ModelId) -> Option<ParsedModelId> {
let (name, provided_quant) = match model_id {
ModelId::Name(name, quant) => (name.as_str(), quant.as_ref()),
_ => return None,
};
let parts: Vec<&str> = name.split(':').collect();
let (repo_id, revision, string_quantization) = match parts.as_slice() {
[repo] => {
(repo.to_string(), "main".to_string(), None)
}
[repo, quant_or_rev] => {
if looks_like_quantization(quant_or_rev) {
(
repo.to_string(),
"main".to_string(),
Some(quant_or_rev.to_string()),
)
} else {
(repo.to_string(), quant_or_rev.to_string(), None)
}
}
[repo, rev, quant] => {
(repo.to_string(), rev.to_string(), Some(quant.to_string()))
}
_ => return None,
};
let final_quant = if let Some(quant) = provided_quant {
Some(quant.to_filename_format())
} else if let Some(q) = string_quantization {
Some(q)
} else {
self.default_quantization.clone()
};
Some(ParsedModelId {
repo_id,
revision,
quantization: final_quant,
})
}
#[must_use]
pub fn quantization_to_filename_pattern(quantization: &str) -> String {
format!("*{}.gguf", quantization.to_uppercase())
}
fn find_cached_file(&self, repo_id: &str, quantization: Option<&str>) -> Option<PathBuf> {
let repo_path = self.cache_dir.join(repo_id.replace('/', "--"));
if !repo_path.exists() {
return None;
}
let pattern = quantization.map_or_else(
|| "*.gguf".to_string(),
Self::quantization_to_filename_pattern,
);
if let Ok(entries) = std::fs::read_dir(&repo_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "gguf") {
let filename = path.file_name()?.to_str()?;
if pattern_matches(&pattern, filename) {
return Some(path);
}
}
}
}
None
}
pub fn download_model(&self, parsed: &ParsedModelId) -> ModelProviderResult<PathBuf> {
if let Some(cached_path) =
self.find_cached_file(&parsed.repo_id, parsed.quantization.as_deref())
{
tracing::debug!("Found cached model: {:?}", cached_path);
return Ok(cached_path);
}
tracing::trace!(
"---------------Downloading model {} (revision: {}, quantization: {:?})",
parsed.repo_id,
parsed.revision,
parsed.quantization
);
let repo = self.hf_client.model(
parsed.repo_id.split('/').next().unwrap_or("").to_string(),
parsed
.repo_id
.split('/')
.skip(1)
.collect::<Vec<_>>()
.join("/"),
);
let filename = if let Some(quant) = &parsed.quantization {
tracing::trace!("------------get model revision for quantization");
find_gguf_file_in_repo(&repo, &parsed.revision, quant)?
} else {
"*.gguf".to_string()
};
let dest_dir = self.cache_dir.join(parsed.repo_id.replace('/', "--"));
std::fs::create_dir_all(&dest_dir).map_err(|e| {
ModelProviderErrors::FailedFetching(Box::new(std::io::Error::other(format!(
"Failed to create destination directory: {e}"
))))
})?;
let params = RepoDownloadFileParams {
revision: Some(parsed.revision.clone()),
filename: filename.clone(),
directory: dest_dir.clone(),
};
tracing::trace!("------------download model file");
let downloaded_path = repository::repo_download_file(&repo, ¶ms).map_err(|e| {
ModelProviderErrors::FailedFetching(Box::new(std::io::Error::other(format!(
"Failed to download model: {e}"
))))
})?;
tracing::info!("Downloaded model to: {:?}", downloaded_path);
Ok(downloaded_path)
}
}
impl ModelProvider for HuggingFaceGGUFProvider {
type Config = HuggingFaceGGUFConfig;
type Model = LlamaModels;
fn create(self, config: Option<Self::Config>) -> ModelProviderResult<Self>
where
Self: Sized,
{
if let Some(config) = config {
HuggingFaceGGUFProvider::new(config)
} else {
Ok(self)
}
}
fn describe(&self) -> ModelProviderResult<ModelProviderDescriptor> {
Ok(ModelProviderDescriptor {
id: "huggingface",
name: "HuggingFace Hub (llama.cpp)",
reasoning: false,
api: ModelAPI::Custom("huggingface-hub".to_string()),
provider: ModelProviders::HUGGINGFACE,
base_url: Some("https://huggingface.co"),
inputs: MessageType::Text,
cost: ModelUsageCosting {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 4096,
max_tokens: 2048,
})
}
fn get_model(&self, model_id: ModelId) -> ModelProviderResult<Self::Model> {
let parsed = self.parse_model_id(&model_id).ok_or_else(|| {
ModelProviderErrors::NotFound(format!(
"Invalid HuggingFace ModelId: {model_id:?}. Expected format: 'owner/repo[:quantization]'"
))
})?;
let model_path = self.download_model(&parsed)?;
let model_spec = ModelSpec {
name: parsed.repo_id.clone(),
id: model_id.clone(),
devices: None,
model_location: Some(model_path),
lora_location: None,
};
tracing::trace!("get_model: model_spec={model_spec:?} from llamacpp");
self.llama_backend
.load_model(model_spec, &self.llama_config)
}
fn get_model_by_spec(&self, _model_spec: ModelSpec) -> ModelProviderResult<Self::Model> {
Err(ModelProviderErrors::NotFound(
"Use get_model with HuggingFace ModelId syntax instead".to_string(),
))
}
fn get_one(&self, _model_id: ModelId) -> ModelProviderResult<ModelSpec> {
Err(ModelProviderErrors::NotFound(
"Model catalog not implemented".to_string(),
))
}
fn get_all(&self, _model_id: ModelId) -> ModelProviderResult<Vec<ModelSpec>> {
Err(ModelProviderErrors::NotFound(
"Model catalog not implemented".to_string(),
))
}
}
fn looks_like_quantization(s: &str) -> bool {
let s_lower = s.to_lowercase();
s_lower.starts_with('q') && (s_lower.contains("_k_") || s_lower.contains('_'))
}
fn pattern_matches(pattern: &str, filename: &str) -> bool {
let basename = filename.rsplit('/').next().unwrap_or(filename);
let pattern_no_ext = pattern.strip_suffix(".gguf").unwrap_or(pattern);
let basename_no_ext = basename.strip_suffix(".gguf").unwrap_or(basename);
let pattern_quant = pattern_no_ext.strip_prefix('*').unwrap_or(pattern_no_ext);
basename_no_ext.contains(pattern_quant)
}
fn find_gguf_file_in_repo(
repo: &HFRepository,
revision: &str,
quantization: &str,
) -> ModelProviderResult<String> {
use foundation_core::valtron::Stream;
use foundation_deployment_huggingface::RepoListTreeParams;
let params = RepoListTreeParams {
revision: Some(revision.to_string()),
recursive: Some(true),
limit: None,
};
let tree = foundation_deployment_huggingface::repository::repo_list_tree(repo, ¶ms)
.map_err(|e| {
ModelProviderErrors::FailedFetching(Box::new(std::io::Error::other(format!(
"Failed to list repository files: {e}"
))))
})?;
let entries: Vec<_> = tree
.filter_map(|s| match s {
Stream::Next(Ok(entry)) => Some(entry),
_ => None,
})
.collect();
let pattern = HuggingFaceGGUFProvider::quantization_to_filename_pattern(quantization);
for entry in &entries {
if let foundation_deployment_huggingface::RepoTreeEntry::File { path, .. } = entry {
if std::path::Path::new(path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
&& pattern_matches(&pattern, path)
{
tracing::info!(
"Found relevant GGUF model for pattern: {} to be: {}",
&pattern,
&path,
);
return Ok(path.clone());
}
}
}
for entry in &entries {
if let foundation_deployment_huggingface::RepoTreeEntry::File { path, .. } = entry {
if std::path::Path::new(path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
{
tracing::warn!(
"Exact quantization {} not found, using: {}",
quantization,
path
);
return Ok(path.clone());
}
}
}
Err(ModelProviderErrors::NotFound(format!(
"No GGUF file matching quantization '{quantization}' found in repository"
)))
}