Initial ForgeLM import

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-29 19:10:57 -03:00
commit a392b9dc60
4296 files changed
+1212675

No files matched your search

@@ -0,0 +1,135 @@
use reqwest::header::{AUTHORIZATION, USER_AGENT};
use serde::Deserialize;
use super::types::{HuggingFaceModelResult, HuggingFaceVerifiedFile};
#[derive(Deserialize, Debug)]
struct HubSibling {
rfilename: String,
size: Option<u64>,
}
#[derive(Deserialize, Debug)]
struct HubModel {
#[serde(default)]
id: String,
#[serde(default)]
author: Option<String>,
siblings: Option<Vec<HubSibling>>,
}
fn quantization_label(path: &str) -> Option<String> {
let file_name = path.rsplit('/').next().unwrap_or(path);
let stem = file_name.strip_suffix(".gguf").unwrap_or(file_name);
stem.rsplit_once('-').map(|(_, suffix)| suffix.to_string())
}
#[cfg(test)]
pub fn filter_verified_gguf_files(payload: &str) -> Result<Vec<HuggingFaceVerifiedFile>, serde_json::Error> {
let model: HubModel = serde_json::from_str(payload)?;
Ok(verified_files_from_model(model))
}
fn verified_files_from_model(model: HubModel) -> Vec<HuggingFaceVerifiedFile> {
let HubModel { siblings, .. } = model;
let mut files: Vec<HuggingFaceVerifiedFile> = siblings
.unwrap_or_default()
.into_iter()
.filter(|sibling| sibling.rfilename.to_ascii_lowercase().ends_with(".gguf"))
.filter(|sibling| !sibling.rfilename.to_ascii_lowercase().contains("mmproj"))
.map(|sibling| HuggingFaceVerifiedFile {
quantization: quantization_label(&sibling.rfilename),
path: sibling.rfilename,
size: sibling.size,
})
.collect();
files.sort_by(|left, right| left.path.cmp(&right.path));
files
}
pub async fn search_hugging_face_models(
query: &str,
token: Option<&str>,
) -> Result<Vec<HuggingFaceModelResult>, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let client = reqwest::Client::new();
let mut request = client
.get("https://huggingface.co/api/models")
.query(&[
("search", trimmed),
("filter", "gguf"),
("limit", "12"),
("full", "true"),
])
.header(USER_AGENT, "ForgeLM/0.1");
if let Some(value) = token.map(str::trim).filter(|value| !value.is_empty()) {
request = request.header(AUTHORIZATION, format!("Bearer {value}"));
}
let payload = request
.send()
.await
.map_err(|error| format!("Hugging Face request failed: {error}"))?
.error_for_status()
.map_err(|error| format!("Hugging Face request failed: {error}"))?
.text()
.await
.map_err(|error| format!("Hugging Face payload read failed: {error}"))?;
let models: Vec<HubModel> = serde_json::from_str(&payload)
.map_err(|error| format!("Hugging Face payload parse failed: {error}"))?;
let mut results: Vec<HuggingFaceModelResult> = models
.into_iter()
.filter_map(|model| {
let repo_id = model.id.clone();
let author = model.author.clone().unwrap_or_default();
let verified_files = verified_files_from_model(model);
if verified_files.is_empty() {
return None;
}
Some(HuggingFaceModelResult {
repo_id,
author,
verified_files,
})
})
.collect();
results.sort_by(|left, right| left.repo_id.cmp(&right.repo_id));
Ok(results)
}
#[cfg(test)]
mod tests {
use super::filter_verified_gguf_files;
#[test]
fn search_hf_gguf_filters_verified_files_and_excludes_mmproj() {
let payload = r#"
{
"siblings": [
{ "rfilename": "README.md" },
{ "rfilename": "model-q4_k_m.gguf" },
{ "rfilename": "subdir/model-q8_0.gguf" },
{ "rfilename": "mmproj-model-f16.gguf" }
]
}
"#;
let files = filter_verified_gguf_files(payload).expect("parse hugging face payload");
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "model-q4_k_m.gguf");
assert_eq!(files[0].quantization.as_deref(), Some("q4_k_m"));
assert_eq!(files[1].path, "subdir/model-q8_0.gguf");
assert_eq!(files[1].quantization.as_deref(), Some("q8_0"));
}
}
@@ -0,0 +1,119 @@
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::types::ModelLibraryEntry;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct ModelLibraryIndex {
pub entries: Vec<ModelLibraryEntry>,
}
pub fn load_index(path: &Path) -> Result<ModelLibraryIndex, String> {
match fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents)
.map_err(|error| format!("Failed to parse model library index: {error}")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(ModelLibraryIndex::default())
}
Err(error) => Err(format!("Failed to read model library index: {error}")),
}
}
pub fn save_index(path: &Path, index: &ModelLibraryIndex) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create model library directory: {error}"))?;
}
let json = serde_json::to_string_pretty(index)
.map_err(|error| format!("Failed to serialize model library index: {error}"))?;
fs::write(path, json).map_err(|error| format!("Failed to write model library index: {error}"))
}
pub fn list_entries(path: &Path) -> Result<Vec<ModelLibraryEntry>, String> {
Ok(load_index(path)?.entries)
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{list_entries, load_index, save_index, ModelLibraryIndex};
fn unique_temp_path(name: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("forgelm-{name}-{nanos}.json"))
}
#[test]
fn local_index_roundtrip_preserves_entries() {
let index = ModelLibraryIndex {
entries: vec![super::ModelLibraryEntry {
id: "managed:phi-4-mini".into(),
label: "Phi 4 Mini".into(),
source: "huggingface".into(),
path: "C:/Users/demo/AppData/Roaming/ForgeLM/models/phi-4-mini.gguf".into(),
}],
};
let json = serde_json::to_string(&index).expect("serialize index");
let restored: ModelLibraryIndex = serde_json::from_str(&json).expect("deserialize index");
assert_eq!(restored, index);
}
#[test]
fn load_index_returns_default_when_file_is_missing() {
let path = unique_temp_path("missing-index");
let restored = load_index(&path).expect("load default index");
assert!(restored.entries.is_empty());
}
#[test]
fn save_and_load_index_roundtrip_from_disk() {
let path = unique_temp_path("roundtrip-index");
let index = ModelLibraryIndex {
entries: vec![super::ModelLibraryEntry {
id: "managed:phi-4-mini".into(),
label: "Phi 4 Mini".into(),
source: "huggingface".into(),
path: "C:/Users/demo/AppData/Roaming/ForgeLM/models/phi-4-mini.gguf".into(),
}],
};
save_index(&path, &index).expect("save index");
let restored = load_index(&path).expect("load saved index");
assert_eq!(restored, index);
let _ = std::fs::remove_file(path);
}
#[test]
fn list_entries_reads_saved_entries() {
let path = unique_temp_path("list-entries");
let index = ModelLibraryIndex {
entries: vec![super::ModelLibraryEntry {
id: "managed:phi-4-mini".into(),
label: "Phi 4 Mini".into(),
source: "huggingface".into(),
path: "C:/Users/demo/AppData/Roaming/ForgeLM/models/phi-4-mini.gguf".into(),
}],
};
save_index(&path, &index).expect("save index");
let entries = list_entries(&path).expect("list entries");
assert_eq!(entries, index.entries);
let _ = std::fs::remove_file(path);
}
}
@@ -0,0 +1,4 @@
pub mod huggingface;
pub mod local_index;
pub mod paths;
pub mod types;
@@ -0,0 +1,37 @@
use std::path::{Path, PathBuf};
const MODEL_LIBRARY_DIR: &str = "model-library";
const INDEX_FILE: &str = "index.json";
const MANAGED_MODELS_DIR: &str = "managed-models";
pub fn model_library_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join(MODEL_LIBRARY_DIR)
}
pub fn model_library_index_path(app_data_dir: &Path) -> PathBuf {
model_library_dir(app_data_dir).join(INDEX_FILE)
}
pub fn managed_models_dir(app_data_dir: &Path) -> PathBuf {
model_library_dir(app_data_dir).join(MANAGED_MODELS_DIR)
}
#[cfg(test)]
mod tests {
use super::{managed_models_dir, model_library_dir, model_library_index_path};
#[test]
fn model_library_paths_live_under_app_data_dir() {
let root = std::path::Path::new("C:/Users/demo/AppData/Roaming/ForgeLM");
assert_eq!(model_library_dir(root), root.join("model-library"));
assert_eq!(
model_library_index_path(root),
root.join("model-library").join("index.json")
);
assert_eq!(
managed_models_dir(root),
root.join("model-library").join("managed-models")
);
}
}
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum RemoteProviderKind {
OpenAi,
OpenRouter,
Anthropic,
Custom,
#[default]
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct RemotePreset {
pub id: String,
pub label: String,
pub provider: RemoteProviderKind,
pub base_url: String,
pub default_model: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct ModelLibraryEntry {
pub id: String,
pub label: String,
pub source: String,
pub path: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct HuggingFaceVerifiedFile {
pub path: String,
pub size: Option<u64>,
pub quantization: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct HuggingFaceModelResult {
pub repo_id: String,
pub author: String,
pub verified_files: Vec<HuggingFaceVerifiedFile>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)]
pub struct ModelLibraryState {
pub entries: Vec<ModelLibraryEntry>,
pub managed_dir: String,
}