Initial ForgeLM import
This commit is contained in:
commit
a392b9dc60
4296 files changed
+1212675
No files matched your search
@@ -0,0 +1,69 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GpuInfo {
|
||||
pub name: String,
|
||||
pub vram_mib: u32,
|
||||
pub recommended_layers: i32,
|
||||
}
|
||||
|
||||
fn parse_gpu_info(stdout: &str) -> Result<GpuInfo, String> {
|
||||
let line = stdout.lines().next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
return Err("No GPU found".into());
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, ',').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(format!("Unexpected nvidia-smi output: {line}"));
|
||||
}
|
||||
|
||||
let name = parts[0].trim().to_string();
|
||||
let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0);
|
||||
let usable = vram_mib.saturating_sub(1024);
|
||||
let recommended_layers = (usable / 80).min(200) as i32;
|
||||
|
||||
Ok(GpuInfo {
|
||||
name,
|
||||
vram_mib,
|
||||
recommended_layers,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn query_gpu() -> Result<GpuInfo, String> {
|
||||
let output = Command::new("nvidia-smi")
|
||||
.args([
|
||||
"--query-gpu=name,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
.output()
|
||||
.map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("nvidia-smi failed — no NVIDIA GPU detected".into());
|
||||
}
|
||||
|
||||
parse_gpu_info(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_gpu_info;
|
||||
|
||||
#[test]
|
||||
fn parse_gpu_info_accepts_valid_csv_line() {
|
||||
let info = parse_gpu_info("RTX 4090,24564\n").expect("parse gpu info");
|
||||
|
||||
assert_eq!(info.name, "RTX 4090");
|
||||
assert_eq!(info.vram_mib, 24564);
|
||||
assert_eq!(info.recommended_layers, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gpu_info_rejects_malformed_output() {
|
||||
let error = parse_gpu_info("RTX 4090").expect_err("reject malformed output");
|
||||
|
||||
assert!(error.contains("Unexpected nvidia-smi output"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
mod gpu;
|
||||
mod model_library;
|
||||
mod model_files;
|
||||
mod runtime;
|
||||
|
||||
use runtime::process::find_localllm_binary;
|
||||
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
|
||||
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
|
||||
use gpu::query_gpu;
|
||||
use model_library::huggingface::search_hugging_face_models;
|
||||
use model_library::local_index::list_entries;
|
||||
use model_library::paths::{managed_models_dir, model_library_index_path};
|
||||
use model_library::types::{ModelLibraryEntry, ModelLibraryState};
|
||||
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
|
||||
|
||||
// ─── Server lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap_runtime_command(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
bootstrap_runtime(app, state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_runtime_snapshot_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
get_runtime_snapshot(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_runtime_config_command_handler(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
config: RuntimeConfig,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
save_runtime_config_command(app, state, config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_runtime_command(
|
||||
app: AppHandle,
|
||||
state: State<RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
start_runtime(app, state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_runtime_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
stop_runtime(state)
|
||||
}
|
||||
|
||||
/// Returns the resolved path to the localllm binary (useful for debugging).
|
||||
#[tauri::command]
|
||||
fn find_server_binary() -> Result<String, String> {
|
||||
find_localllm_binary()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.ok_or_else(|| {
|
||||
"localllm binary not found. Build with: cargo build --release -p inference-server"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the app data directory path (useful for debugging).
|
||||
#[tauri::command]
|
||||
fn get_app_data_dir(app: AppHandle) -> Result<String, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map_err(|e| format!("Cannot resolve app data dir: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search_hugging_face_models_command(
|
||||
query: String,
|
||||
token: Option<String>,
|
||||
) -> Result<Vec<model_library::types::HuggingFaceModelResult>, String> {
|
||||
search_hugging_face_models(&query, token.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_model_library_entries_command(app: AppHandle) -> Result<Vec<ModelLibraryEntry>, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
|
||||
let index_path = model_library_index_path(&app_data_dir);
|
||||
list_entries(&index_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_model_library_state_command(app: AppHandle) -> Result<ModelLibraryState, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve app data dir: {error}"))?;
|
||||
let index_path = model_library_index_path(&app_data_dir);
|
||||
|
||||
Ok(ModelLibraryState {
|
||||
entries: list_entries(&index_path)?,
|
||||
managed_dir: managed_models_dir(&app_data_dir).to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── App entry ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(RuntimeState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
query_gpu,
|
||||
scan_models,
|
||||
pick_models_dir,
|
||||
pick_model_files,
|
||||
import_model,
|
||||
search_hugging_face_models_command,
|
||||
list_model_library_entries_command,
|
||||
get_model_library_state_command,
|
||||
bootstrap_runtime_command,
|
||||
get_runtime_snapshot_command,
|
||||
save_runtime_config_command_handler,
|
||||
start_runtime_command,
|
||||
stop_runtime_command,
|
||||
get_app_data_dir,
|
||||
find_server_binary,
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
forgelm_chat_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModelFile {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct PickDirResult {
|
||||
pub dir: String,
|
||||
pub models: Vec<ModelFile>,
|
||||
}
|
||||
|
||||
fn model_file_from_path(path: &Path) -> ModelFile {
|
||||
ModelFile {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_model_dir(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("Directory not found: {}", path.display()));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
|
||||
let mut models = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
models.push(model_file_from_path(&path));
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
|
||||
if !src.exists() {
|
||||
return Err(format!("Source file not found: {}", src.display()));
|
||||
}
|
||||
if !src.is_file() {
|
||||
return Err("Source is not a file".into());
|
||||
}
|
||||
if !src
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.eq_ignore_ascii_case("gguf")
|
||||
{
|
||||
return Err("Only .gguf files are supported".into());
|
||||
}
|
||||
|
||||
if !dest_dir.exists() {
|
||||
std::fs::create_dir_all(dest_dir)
|
||||
.map_err(|e| format!("Failed to create models directory: {e}"))?;
|
||||
}
|
||||
|
||||
let file_name = src.file_name().unwrap_or_default();
|
||||
let dest_path = dest_dir.join(file_name);
|
||||
|
||||
if src == dest_path {
|
||||
return Ok(model_file_from_path(&dest_path));
|
||||
}
|
||||
|
||||
std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?;
|
||||
Ok(model_file_from_path(&dest_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
|
||||
scan_model_dir(Path::new(&dir))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_models_dir(app: AppHandle) -> Result<PickDirResult, String> {
|
||||
let folder = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Select models folder")
|
||||
.blocking_pick_folder();
|
||||
|
||||
match folder {
|
||||
Some(path) => {
|
||||
let dir = path.to_string();
|
||||
let models = scan_model_dir(Path::new(&dir))?;
|
||||
Ok(PickDirResult { dir, models })
|
||||
}
|
||||
None => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_model_files(app: AppHandle) -> Result<Vec<ModelFile>, String> {
|
||||
let files = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Select model files")
|
||||
.add_filter("GGUF models", &["gguf"])
|
||||
.blocking_pick_files();
|
||||
|
||||
match files {
|
||||
Some(paths) => Ok(paths.iter().map(|path| model_file_from_path(Path::new(&path.to_string()))).collect()),
|
||||
None => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String> {
|
||||
import_model_file(Path::new(&src_path), Path::new(&dest_dir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{import_model_file, scan_model_dir};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("forgelm-{name}-{unique}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_filters_and_sorts_gguf_files() {
|
||||
let dir = temp_path("scan-models");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
std::fs::write(dir.join("zeta.gguf"), b"demo").expect("write zeta");
|
||||
std::fs::write(dir.join("alpha.gguf"), b"demo").expect("write alpha");
|
||||
std::fs::write(dir.join("notes.txt"), b"ignore").expect("write non-model file");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.into_iter().map(|model| model.name).collect();
|
||||
|
||||
assert_eq!(names, vec!["alpha.gguf", "zeta.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_ignores_directories_named_like_models() {
|
||||
let dir = temp_path("scan-model-dirs");
|
||||
std::fs::create_dir_all(dir.join("folder.gguf")).expect("create fake model dir");
|
||||
std::fs::write(dir.join("real.gguf"), b"demo").expect("write model file");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.into_iter().map(|model| model.name).collect();
|
||||
|
||||
assert_eq!(names, vec!["real.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_model_file_returns_existing_model_when_source_matches_destination() {
|
||||
let dir = temp_path("import-model");
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let model_path = dir.join("demo.gguf");
|
||||
std::fs::write(&model_path, b"demo").expect("write demo model");
|
||||
|
||||
let model = import_model_file(&model_path, &dir).expect("import model file");
|
||||
|
||||
assert_eq!(model.name, "demo.gguf");
|
||||
assert_eq!(PathBuf::from(model.path), model_path);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
|
||||
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?;
|
||||
Ok(data_dir.join("config.json"))
|
||||
}
|
||||
|
||||
pub fn load_runtime_config(app: &AppHandle) -> Result<RuntimeConfig, String> {
|
||||
let path = config_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(RuntimeConfig::default());
|
||||
}
|
||||
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
|
||||
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
|
||||
}
|
||||
|
||||
pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> {
|
||||
let path = config_path(app)?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn resolve_socket_addr(addr: &str) -> Option<std::net::SocketAddr> {
|
||||
match addr.to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.next(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn health_response_is_ready(response: &str) -> bool {
|
||||
let mut sections = response.splitn(2, "\r\n\r\n");
|
||||
let headers = sections.next().unwrap_or_default();
|
||||
let body = sections.next().unwrap_or_default();
|
||||
|
||||
if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
json.get("status")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|status| status == "ok")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn wait_for_health(addr: &str, timeout: Duration) -> bool {
|
||||
let Some(socket_addr) = resolve_socket_addr(addr) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) {
|
||||
Ok(mut stream) => {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
|
||||
|
||||
let request =
|
||||
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
|
||||
if stream.write_all(request.as_bytes()).is_ok() {
|
||||
let mut response = String::new();
|
||||
if stream.read_to_string(&mut response).is_ok()
|
||||
&& health_response_is_ready(&response)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_health(addr: &str, timeout: Duration) -> bool {
|
||||
let Some(socket_addr) = resolve_socket_addr(addr) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match TcpStream::connect_timeout(&socket_addr, timeout) {
|
||||
Ok(mut stream) => {
|
||||
let _ = stream.set_read_timeout(Some(timeout));
|
||||
let _ = stream.set_write_timeout(Some(timeout));
|
||||
|
||||
let request =
|
||||
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
|
||||
if stream.write_all(request.as_bytes()).is_ok() {
|
||||
let mut response = String::new();
|
||||
return stream.read_to_string(&mut response).is_ok()
|
||||
&& health_response_is_ready(&response);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wait_for_health;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn spawn_http_server(response: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
addr.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_accepts_healthy_http_response() {
|
||||
let addr = spawn_http_server(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
|
||||
);
|
||||
|
||||
assert!(wait_for_health(&addr, Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_rejects_non_healthy_payloads() {
|
||||
let addr = spawn_http_server(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}",
|
||||
);
|
||||
|
||||
assert!(!wait_for_health(&addr, Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_health_rejects_plain_open_ports() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port");
|
||||
let addr = listener.local_addr().expect("local addr").to_string();
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok((_stream, _)) = listener.accept() {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
});
|
||||
|
||||
assert!(!wait_for_health(&addr, Duration::from_millis(500)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
pub mod config;
|
||||
pub mod health;
|
||||
pub mod process;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use self::config::{load_runtime_config, save_runtime_config};
|
||||
use self::health::wait_for_health;
|
||||
use self::process::spawn_localllm;
|
||||
use self::state::{
|
||||
begin_lifecycle_command, block_lifecycle_command, build_snapshot, finish_lifecycle_command,
|
||||
guard_start_request, guard_stop_request, refresh_process_state, requires_runtime_restart,
|
||||
runtime_error,
|
||||
};
|
||||
use self::types::{
|
||||
ConnectionMode, RuntimeConfig, RuntimeLifecycleCommand, RuntimeLifecycleOutcome,
|
||||
RuntimeSnapshot, RuntimeStatus, StartPolicy,
|
||||
};
|
||||
|
||||
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
|
||||
pub fn save_runtime_config_command(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
config: RuntimeConfig,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) {
|
||||
let message =
|
||||
"Stop the local API before changing connection mode, model, address, or runtime sizing.";
|
||||
inner.last_error = Some(runtime_error("restart_required", message));
|
||||
block_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Save, message);
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
begin_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Save);
|
||||
if let Err(error) = save_runtime_config(&app, &config) {
|
||||
inner.last_error = Some(runtime_error("save_failed", &error));
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
RuntimeLifecycleCommand::Save,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
format!("Failed to save runtime settings: {error}"),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
inner.config = config;
|
||||
inner.last_error = None;
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
RuntimeLifecycleCommand::Save,
|
||||
RuntimeLifecycleOutcome::Succeeded,
|
||||
"Runtime settings saved.",
|
||||
);
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
|
||||
fn start_runtime_inner(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, RuntimeState>,
|
||||
override_config: Option<RuntimeConfig>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let command = if override_config.is_some() {
|
||||
RuntimeLifecycleCommand::SaveAndStart
|
||||
} else {
|
||||
RuntimeLifecycleCommand::Start
|
||||
};
|
||||
|
||||
let config = {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
if guard_start_request(&mut inner).is_err() {
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
begin_lifecycle_command(&mut inner, command);
|
||||
|
||||
if let Some(config) = override_config {
|
||||
if let Err(error) = save_runtime_config(app, &config) {
|
||||
inner.last_error = Some(runtime_error("save_failed", &error));
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
command,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
format!("Failed to save runtime settings: {error}"),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
inner.config = config;
|
||||
}
|
||||
|
||||
if inner.config.connection_mode != ConnectionMode::Local {
|
||||
let message = "Switch to Local mode before starting the built-in API";
|
||||
inner.last_error = Some(runtime_error("remote_mode", message));
|
||||
inner.status = RuntimeStatus::Error;
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
command,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
message,
|
||||
);
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
inner.status = RuntimeStatus::Starting;
|
||||
inner.last_error = None;
|
||||
inner.config.clone()
|
||||
};
|
||||
|
||||
let child = match spawn_localllm(&config) {
|
||||
Ok(child) => child,
|
||||
Err(message) => {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error("spawn_failed", &message));
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
command,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
message,
|
||||
);
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.child = Some(child);
|
||||
inner.status = RuntimeStatus::Starting;
|
||||
}
|
||||
|
||||
if wait_for_health(&config.addr, Duration::from_secs(15)) {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
inner.last_error = None;
|
||||
let message = if command == RuntimeLifecycleCommand::SaveAndStart {
|
||||
"Runtime settings saved and local API started."
|
||||
} else {
|
||||
"Local API started."
|
||||
};
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
command,
|
||||
RuntimeLifecycleOutcome::Succeeded,
|
||||
message,
|
||||
);
|
||||
Ok(build_snapshot(&inner))
|
||||
} else {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(child) = inner.child.as_mut() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
let message = format!(
|
||||
"Local API did not pass /health readiness on {} in time",
|
||||
config.addr
|
||||
);
|
||||
inner.last_error = Some(runtime_error("health_timeout", &message));
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
command,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
message,
|
||||
);
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bootstrap_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
let config = load_runtime_config(&app).unwrap_or_default();
|
||||
let should_autostart = config.connection_mode == ConnectionMode::Local
|
||||
&& config.start_policy == StartPolicy::OnAppLaunch
|
||||
&& config
|
||||
.model_path
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
{
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
inner.config = config.clone();
|
||||
refresh_process_state(&mut inner);
|
||||
}
|
||||
|
||||
if should_autostart {
|
||||
start_runtime_inner(&app, &state, Some(config))
|
||||
} else {
|
||||
get_runtime_snapshot(state)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, RuntimeState>,
|
||||
) -> Result<RuntimeSnapshot, String> {
|
||||
start_runtime_inner(&app, &state, None)
|
||||
}
|
||||
|
||||
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
|
||||
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
if guard_stop_request(&mut inner).is_err() {
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
|
||||
begin_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Stop);
|
||||
inner.status = RuntimeStatus::Stopping;
|
||||
|
||||
if let Some(child) = inner.child.as_mut() {
|
||||
if let Err(error) = child.kill() {
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"stop_failed",
|
||||
format!("Failed to kill server: {error}"),
|
||||
));
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
RuntimeLifecycleCommand::Stop,
|
||||
RuntimeLifecycleOutcome::Failed,
|
||||
format!("Failed to stop local API: {error}"),
|
||||
);
|
||||
return Ok(build_snapshot(&inner));
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Stopped;
|
||||
inner.last_error = None;
|
||||
finish_lifecycle_command(
|
||||
&mut inner,
|
||||
RuntimeLifecycleCommand::Stop,
|
||||
RuntimeLifecycleOutcome::Succeeded,
|
||||
"Local API stopped.",
|
||||
);
|
||||
Ok(build_snapshot(&inner))
|
||||
}
|
||||
|
||||
pub use state::RuntimeState;
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
|
||||
use super::types::RuntimeConfig;
|
||||
|
||||
pub fn find_localllm_binary() -> Option<PathBuf> {
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
"localllm.exe"
|
||||
} else {
|
||||
"localllm"
|
||||
};
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent();
|
||||
while let Some(d) = dir {
|
||||
let candidate = d.join("target").join("release").join(exe_name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
let sibling_candidate = d.join(exe_name);
|
||||
if sibling_candidate.exists() {
|
||||
return Some(sibling_candidate);
|
||||
}
|
||||
|
||||
dir = d.parent();
|
||||
}
|
||||
}
|
||||
|
||||
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
|
||||
if cwd_candidate.exists() {
|
||||
return Some(cwd_candidate);
|
||||
}
|
||||
|
||||
if let Ok(output) = Command::new(if cfg!(target_os = "windows") {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
})
|
||||
.arg("localllm")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path_str = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = path_str.lines().next().unwrap_or("").trim();
|
||||
if !first_line.is_empty() {
|
||||
return Some(PathBuf::from(first_line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
|
||||
let model_path = config
|
||||
.model_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "Select a local model before starting the API".to_string())?;
|
||||
|
||||
let binary_path = find_localllm_binary().ok_or_else(|| {
|
||||
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
|
||||
Then ensure the binary is in target/release/, PATH, or next to this app."
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Command::new(&binary_path)
|
||||
.env("LOCALLLM_MODEL", model_path)
|
||||
.env("LOCALLLM_ADDR", &config.addr)
|
||||
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
|
||||
.env("LOCALLLM_CTX", config.ctx_size.to_string())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::health::check_health;
|
||||
use super::process::find_localllm_binary;
|
||||
use super::types::{
|
||||
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeLifecycleCommand,
|
||||
RuntimeLifecycleOutcome, RuntimeLifecycleResult, RuntimeSnapshot, RuntimeStatus,
|
||||
};
|
||||
|
||||
pub struct RuntimeInner {
|
||||
pub child: Option<Child>,
|
||||
pub status: RuntimeStatus,
|
||||
pub last_error: Option<RuntimeErrorInfo>,
|
||||
pub active_command: RuntimeLifecycleCommand,
|
||||
pub last_command_result: RuntimeLifecycleResult,
|
||||
pub config: RuntimeConfig,
|
||||
}
|
||||
|
||||
pub struct RuntimeState(pub Mutex<RuntimeInner>);
|
||||
|
||||
impl RuntimeState {
|
||||
pub fn new() -> Self {
|
||||
Self(Mutex::new(RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Stopped,
|
||||
last_error: None,
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig::default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
|
||||
RuntimeErrorInfo {
|
||||
code: code.to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lifecycle_result(
|
||||
command: RuntimeLifecycleCommand,
|
||||
outcome: RuntimeLifecycleOutcome,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeLifecycleResult {
|
||||
RuntimeLifecycleResult {
|
||||
command,
|
||||
outcome,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_lifecycle_command(inner: &mut RuntimeInner, command: RuntimeLifecycleCommand) {
|
||||
inner.active_command = command;
|
||||
}
|
||||
|
||||
pub(crate) fn finish_lifecycle_command(
|
||||
inner: &mut RuntimeInner,
|
||||
command: RuntimeLifecycleCommand,
|
||||
outcome: RuntimeLifecycleOutcome,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
inner.active_command = RuntimeLifecycleCommand::Idle;
|
||||
inner.last_command_result = lifecycle_result(command, outcome, message);
|
||||
}
|
||||
|
||||
pub(crate) fn block_lifecycle_command(
|
||||
inner: &mut RuntimeInner,
|
||||
command: RuntimeLifecycleCommand,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeLifecycleResult {
|
||||
let result = lifecycle_result(command, RuntimeLifecycleOutcome::Blocked, message);
|
||||
inner.last_command_result = result.clone();
|
||||
result
|
||||
}
|
||||
|
||||
fn in_flight_command_message(command: RuntimeLifecycleCommand) -> Option<&'static str> {
|
||||
match command {
|
||||
RuntimeLifecycleCommand::Start => Some("Local API start already in progress."),
|
||||
RuntimeLifecycleCommand::Stop => Some("Local API stop already in progress."),
|
||||
RuntimeLifecycleCommand::Save => Some("Runtime settings save already in progress."),
|
||||
RuntimeLifecycleCommand::SaveAndStart => {
|
||||
Some("Runtime save and start already in progress.")
|
||||
}
|
||||
RuntimeLifecycleCommand::Idle => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guard_start_request(inner: &mut RuntimeInner) -> Result<(), RuntimeLifecycleResult> {
|
||||
if let Some(message) = in_flight_command_message(inner.active_command) {
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Start,
|
||||
message,
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
inner.status,
|
||||
RuntimeStatus::Starting | RuntimeStatus::Stopping
|
||||
) {
|
||||
let message = if inner.status == RuntimeStatus::Stopping {
|
||||
"Local API stop already in progress."
|
||||
} else {
|
||||
"Local API start already in progress."
|
||||
};
|
||||
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Start,
|
||||
message,
|
||||
));
|
||||
}
|
||||
|
||||
if inner.child.is_some() {
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Start,
|
||||
"Stop the local API before starting it again.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn guard_stop_request(inner: &mut RuntimeInner) -> Result<(), RuntimeLifecycleResult> {
|
||||
if let Some(message) = in_flight_command_message(inner.active_command) {
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Stop,
|
||||
message,
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
inner.status,
|
||||
RuntimeStatus::Starting | RuntimeStatus::Stopping
|
||||
) {
|
||||
let message = if inner.status == RuntimeStatus::Starting {
|
||||
"Local API start already in progress."
|
||||
} else {
|
||||
"Local API stop already in progress."
|
||||
};
|
||||
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Stop,
|
||||
message,
|
||||
));
|
||||
}
|
||||
|
||||
if inner.child.is_none() {
|
||||
return Err(block_lifecycle_command(
|
||||
inner,
|
||||
RuntimeLifecycleCommand::Stop,
|
||||
"Local API is already stopped.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
|
||||
match config.connection_mode {
|
||||
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
|
||||
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
|
||||
current.connection_mode != next.connection_mode
|
||||
|| current.model_path != next.model_path
|
||||
|| current.addr != next.addr
|
||||
|| current.gpu_layers != next.gpu_layers
|
||||
|| current.ctx_size != next.ctx_size
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_process_state(inner: &mut RuntimeInner) {
|
||||
let Some(child) = inner.child.as_mut() else {
|
||||
if matches!(
|
||||
inner.status,
|
||||
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
|
||||
) {
|
||||
inner.status = RuntimeStatus::Stopped;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"unexpected_exit",
|
||||
format!("Local API exited unexpectedly with status {status}"),
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
if inner.status == RuntimeStatus::Stopped {
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
}
|
||||
|
||||
if inner.config.connection_mode == ConnectionMode::Local
|
||||
&& matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded)
|
||||
{
|
||||
if check_health(&inner.config.addr, Duration::from_millis(400)) {
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
if inner.last_error.as_ref().map(|error| error.code.as_str())
|
||||
== Some("health_check_failed")
|
||||
{
|
||||
inner.last_error = None;
|
||||
}
|
||||
} else {
|
||||
inner.status = RuntimeStatus::Degraded;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"health_check_failed",
|
||||
format!(
|
||||
"Local API process is still running, but /health is no longer ready on {}.",
|
||||
inner.config.addr
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
inner.child = None;
|
||||
inner.status = RuntimeStatus::Error;
|
||||
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
|
||||
RuntimeSnapshot {
|
||||
status: inner.status,
|
||||
ready: inner.status == RuntimeStatus::Ready,
|
||||
process_running: inner.child.is_some(),
|
||||
resolved_url: resolve_runtime_url(&inner.config),
|
||||
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
|
||||
last_error: inner.last_error.clone(),
|
||||
active_command: inner.active_command,
|
||||
last_command_result: inner.last_command_result.clone(),
|
||||
config: inner.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spawn_healthy_server() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("read healthy addr")
|
||||
.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = stream.write_all(
|
||||
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
|
||||
);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
fn unused_local_addr() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port");
|
||||
let addr = listener.local_addr().expect("read local addr").to_string();
|
||||
drop(listener);
|
||||
addr
|
||||
}
|
||||
|
||||
fn spawn_sleeping_child() -> Child {
|
||||
std::process::Command::new("python")
|
||||
.args(["-c", "import time; time.sleep(60)"])
|
||||
.spawn()
|
||||
.expect("spawn sleeping child")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() {
|
||||
let mut child = spawn_sleeping_child();
|
||||
let addr = unused_local_addr();
|
||||
|
||||
let (status, error_code) = {
|
||||
let mut inner = RuntimeInner {
|
||||
child: Some(child),
|
||||
status: RuntimeStatus::Ready,
|
||||
last_error: None,
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig {
|
||||
model_path: Some("D:/models/demo.gguf".to_string()),
|
||||
addr,
|
||||
..RuntimeConfig::default()
|
||||
},
|
||||
};
|
||||
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
child = inner.child.take().expect("process remains managed");
|
||||
|
||||
let error_code: Option<String> =
|
||||
inner.last_error.as_ref().map(|error| error.code.clone());
|
||||
|
||||
(inner.status, error_code)
|
||||
};
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(status, RuntimeStatus::Degraded);
|
||||
assert_eq!(error_code.as_deref(), Some("health_check_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() {
|
||||
let mut child = spawn_sleeping_child();
|
||||
let addr = spawn_healthy_server();
|
||||
|
||||
let (status, error_code) = {
|
||||
let mut inner = RuntimeInner {
|
||||
child: Some(child),
|
||||
status: RuntimeStatus::Degraded,
|
||||
last_error: Some(runtime_error(
|
||||
"health_check_failed",
|
||||
"Local API process is still running, but /health is no longer ready.",
|
||||
)),
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig {
|
||||
model_path: Some("D:/models/demo.gguf".to_string()),
|
||||
addr,
|
||||
..RuntimeConfig::default()
|
||||
},
|
||||
};
|
||||
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
child = inner.child.take().expect("process remains managed");
|
||||
|
||||
let error_code: Option<String> =
|
||||
inner.last_error.as_ref().map(|error| error.code.clone());
|
||||
|
||||
(inner.status, error_code)
|
||||
};
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(status, RuntimeStatus::Ready);
|
||||
assert_eq!(error_code, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_snapshot_serializes_lifecycle_command_metadata() {
|
||||
let inner = RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Stopped,
|
||||
last_error: None,
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig::default(),
|
||||
};
|
||||
|
||||
let snapshot = build_snapshot(&inner);
|
||||
let json = serde_json::to_value(snapshot).expect("serialize snapshot");
|
||||
|
||||
assert_eq!(
|
||||
json.get("active_command").and_then(|value| value.as_str()),
|
||||
Some("idle")
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("last_command_result")
|
||||
.and_then(|value| value.get("command"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("idle")
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("last_command_result")
|
||||
.and_then(|value| value.get("outcome"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("succeeded")
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("last_command_result")
|
||||
.and_then(|value| value.get("message"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Runtime idle.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_snapshot_keeps_existing_runtime_fields() {
|
||||
let inner = RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Error,
|
||||
last_error: Some(runtime_error("spawn_failed", "boom")),
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig::default(),
|
||||
};
|
||||
|
||||
let snapshot = build_snapshot(&inner);
|
||||
let json = serde_json::to_value(snapshot).expect("serialize snapshot");
|
||||
|
||||
for key in [
|
||||
"status",
|
||||
"ready",
|
||||
"process_running",
|
||||
"resolved_url",
|
||||
"binary_path",
|
||||
"last_error",
|
||||
"config",
|
||||
] {
|
||||
assert!(json.get(key).is_some(), "missing snapshot field: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_start_request_blocks_when_runtime_is_mid_transition() {
|
||||
let mut inner = RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Starting,
|
||||
last_error: None,
|
||||
active_command: RuntimeLifecycleCommand::Start,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig::default(),
|
||||
};
|
||||
|
||||
let blocked = guard_start_request(&mut inner).expect_err("start should be blocked");
|
||||
|
||||
assert_eq!(blocked.command, RuntimeLifecycleCommand::Start);
|
||||
assert_eq!(
|
||||
blocked.outcome,
|
||||
super::super::types::RuntimeLifecycleOutcome::Blocked
|
||||
);
|
||||
assert_eq!(blocked.message, "Local API start already in progress.");
|
||||
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_stop_request_blocks_when_runtime_is_already_stopped() {
|
||||
let mut inner = RuntimeInner {
|
||||
child: None,
|
||||
status: RuntimeStatus::Stopped,
|
||||
last_error: None,
|
||||
active_command: RuntimeLifecycleCommand::Idle,
|
||||
last_command_result: RuntimeLifecycleResult::default(),
|
||||
config: RuntimeConfig::default(),
|
||||
};
|
||||
|
||||
let blocked = guard_stop_request(&mut inner).expect_err("stop should be blocked");
|
||||
|
||||
assert_eq!(blocked.command, RuntimeLifecycleCommand::Stop);
|
||||
assert_eq!(
|
||||
blocked.outcome,
|
||||
super::super::types::RuntimeLifecycleOutcome::Blocked
|
||||
);
|
||||
assert_eq!(blocked.message, "Local API is already stopped.");
|
||||
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_runtime_restart_only_considers_backend_restart_fields() {
|
||||
let current = RuntimeConfig::default();
|
||||
|
||||
let mut changing_addr = current.clone();
|
||||
changing_addr.addr = "127.0.0.1:9090".to_string();
|
||||
assert!(requires_runtime_restart(¤t, &changing_addr));
|
||||
|
||||
let mut changing_models_dir = current.clone();
|
||||
changing_models_dir.models_dir = Some("D:/other-models".to_string());
|
||||
assert!(!requires_runtime_restart(¤t, &changing_models_dir));
|
||||
|
||||
let mut changing_selected_model = current.clone();
|
||||
changing_selected_model.selected_model_id = Some("demo-model".to_string());
|
||||
assert!(!requires_runtime_restart(
|
||||
¤t,
|
||||
&changing_selected_model
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model_library::types::RemotePreset;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionMode {
|
||||
#[default]
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StartPolicy {
|
||||
#[default]
|
||||
Manual,
|
||||
OnAppLaunch,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeStatus {
|
||||
#[default]
|
||||
Stopped,
|
||||
Starting,
|
||||
Ready,
|
||||
Degraded,
|
||||
Stopping,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeLifecycleCommand {
|
||||
#[default]
|
||||
Idle,
|
||||
Save,
|
||||
Start,
|
||||
Stop,
|
||||
SaveAndStart,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeLifecycleOutcome {
|
||||
#[default]
|
||||
Succeeded,
|
||||
Blocked,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RuntimeLifecycleResult {
|
||||
pub command: RuntimeLifecycleCommand,
|
||||
pub outcome: RuntimeLifecycleOutcome,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for RuntimeLifecycleResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
command: RuntimeLifecycleCommand::Idle,
|
||||
outcome: RuntimeLifecycleOutcome::Succeeded,
|
||||
message: "Runtime idle.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct RuntimeConfig {
|
||||
pub connection_mode: ConnectionMode,
|
||||
pub start_policy: StartPolicy,
|
||||
pub server_url: String,
|
||||
pub api_key: String,
|
||||
pub model_path: Option<String>,
|
||||
pub models_dir: Option<String>,
|
||||
pub addr: String,
|
||||
pub gpu_layers: i32,
|
||||
pub ctx_size: u32,
|
||||
pub extra_model_paths: Vec<String>,
|
||||
pub selected_model_id: Option<String>,
|
||||
pub remote_presets: Vec<RemotePreset>,
|
||||
pub hugging_face_token: String,
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
pub fn default_addr() -> String {
|
||||
"127.0.0.1:8080".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connection_mode: ConnectionMode::Local,
|
||||
start_policy: StartPolicy::Manual,
|
||||
server_url: String::new(),
|
||||
api_key: String::new(),
|
||||
model_path: None,
|
||||
models_dir: None,
|
||||
addr: Self::default_addr(),
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: Vec::new(),
|
||||
selected_model_id: None,
|
||||
remote_presets: Vec::new(),
|
||||
hugging_face_token: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConnectionMode, RuntimeConfig};
|
||||
|
||||
#[test]
|
||||
fn runtime_config_deserializes_old_shape_with_new_defaults() {
|
||||
let legacy = r#"
|
||||
{
|
||||
"connection_mode": "local",
|
||||
"start_policy": "manual",
|
||||
"server_url": "",
|
||||
"api_key": "",
|
||||
"model_path": "D:/models/demo.gguf",
|
||||
"models_dir": "D:/models",
|
||||
"addr": "127.0.0.1:8080",
|
||||
"gpu_layers": 12,
|
||||
"ctx_size": 4096,
|
||||
"extra_model_paths": ["D:/models/demo.gguf"]
|
||||
}
|
||||
"#;
|
||||
|
||||
let config: RuntimeConfig =
|
||||
serde_json::from_str(legacy).expect("deserialize legacy config");
|
||||
|
||||
assert_eq!(config.connection_mode, ConnectionMode::Local);
|
||||
assert_eq!(config.selected_model_id, None);
|
||||
assert!(config.remote_presets.is_empty());
|
||||
assert_eq!(config.hugging_face_token, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct RuntimeErrorInfo {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub status: RuntimeStatus,
|
||||
pub ready: bool,
|
||||
pub process_running: bool,
|
||||
pub resolved_url: String,
|
||||
pub binary_path: Option<String>,
|
||||
pub last_error: Option<RuntimeErrorInfo>,
|
||||
pub active_command: RuntimeLifecycleCommand,
|
||||
pub last_command_result: RuntimeLifecycleResult,
|
||||
pub config: RuntimeConfig,
|
||||
}
|
||||
Reference in new issue
Block a user