Initial ForgeLM import
This commit is contained in:
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,
|
||||
}
|
||||
Reference in new issue
Block a user