update fmt

This commit is contained in:
jhqxxx
2026-02-14 16:34:12 +08:00
14 changed files with 1702 additions and 84 deletions
+330 -8
View File
@@ -1,29 +1,59 @@
use std::pin::pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use aha::models::{GenerateModel, ModelInstance, WhichModel, load_model};
use aha::process::cleanup_pid_file;
use aha::utils::string_to_static_str;
use aha_openai_dive::v1::resources::chat::ChatCompletionParameters;
use rocket::futures::StreamExt;
use rocket::serde::json::Json;
use rocket::serde::{Serialize, json::Json};
use rocket::{
Request,
Request, State,
futures::Stream,
get,
http::{ContentType, Status},
post,
response::{Responder, stream::TextStream},
};
use tokio::sync::RwLock;
static MODEL: OnceLock<Arc<RwLock<ModelInstance<'static>>>> = OnceLock::new();
/// Wrapper to store model type together with the model instance
struct StoredModel {
which_model: WhichModel,
instance: ModelInstance<'static>,
}
static MODEL: OnceLock<Arc<RwLock<StoredModel>>> = OnceLock::new();
static SHUTDOWN_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
static SERVER_PORT: OnceLock<u16> = OnceLock::new();
static ALLOW_REMOTE_SHUTDOWN: OnceLock<bool> = OnceLock::new();
pub fn init(model_type: WhichModel, path: String) -> anyhow::Result<()> {
let model_path = string_to_static_str(path);
let model = load_model(model_type, model_path)?;
MODEL.get_or_init(|| Arc::new(RwLock::new(model)));
MODEL.get_or_init(|| {
Arc::new(RwLock::new(StoredModel {
which_model: model_type,
instance: model,
}))
});
Ok(())
}
pub fn set_server_port(port: u16, allow_remote_shutdown: bool) {
SHUTDOWN_FLAG.get_or_init(|| Arc::new(AtomicBool::new(false)));
SERVER_PORT.get_or_init(|| port);
ALLOW_REMOTE_SHUTDOWN.get_or_init(|| allow_remote_shutdown);
}
#[allow(unused)]
pub fn get_shutdown_flag() -> Arc<AtomicBool> {
SHUTDOWN_FLAG
.get_or_init(|| Arc::new(AtomicBool::new(false)))
.clone()
}
pub(crate) enum Response<R: Stream<Item = String> + Send> {
Stream(TextStream<R>),
Text(String),
@@ -62,7 +92,8 @@ pub(crate) async fn chat(
.cloned()
.ok_or_else(|| anyhow::anyhow!("model not init"))
.unwrap();
model_ref.write().await.generate(req.into_inner())
let mut guard = model_ref.write().await;
guard.instance.generate(req.into_inner())
};
match response {
Ok(res) => {
@@ -76,7 +107,7 @@ pub(crate) async fn chat(
let text_stream = TextStream! {
let model_ref = MODEL.get().cloned().ok_or_else(|| anyhow::anyhow!("model not init")).unwrap();
let mut guard = model_ref.write().await;
let stream_result = guard.generate_stream(req.into_inner());
let stream_result = guard.instance.generate_stream(req.into_inner());
match stream_result {
Ok(stream) => {
let mut stream = pin!(stream);
@@ -113,7 +144,8 @@ pub(crate) async fn remove_background(req: Json<ChatCompletionParameters>) -> (S
.cloned()
.ok_or_else(|| anyhow::anyhow!("model not init"))
.unwrap();
model_ref.write().await.generate(req.into_inner())
let mut guard = model_ref.write().await;
guard.instance.generate(req.into_inner())
};
match response {
Ok(res) => {
@@ -132,7 +164,8 @@ pub(crate) async fn speech(req: Json<ChatCompletionParameters>) -> (Status, Stri
.cloned()
.ok_or_else(|| anyhow::anyhow!("model not init"))
.unwrap();
model_ref.write().await.generate(req.into_inner())
let mut guard = model_ref.write().await;
guard.instance.generate(req.into_inner())
};
match response {
Ok(res) => {
@@ -142,3 +175,292 @@ pub(crate) async fn speech(req: Json<ChatCompletionParameters>) -> (Status, Stri
Err(e) => (Status::InternalServerError, e.to_string()),
}
}
// Health check endpoint
#[derive(Serialize)]
pub(crate) struct HealthResponse {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[get("/health")]
pub(crate) async fn health() -> (Status, (ContentType, Json<HealthResponse>)) {
if MODEL.get().is_some() {
let response = HealthResponse {
status: "ok".to_string(),
error: None,
};
(Status::Ok, (ContentType::JSON, Json(response)))
} else {
let response = HealthResponse {
status: "unhealthy".to_string(),
error: Some("model not initialized".to_string()),
};
(
Status::ServiceUnavailable,
(ContentType::JSON, Json(response)),
)
}
}
// Models endpoint (OpenAI-compatible format)
/// OpenAI-compatible model object
#[derive(Serialize)]
struct ModelObject {
id: String,
object: String,
created: Option<i64>,
owned_by: String,
}
/// OpenAI-compatible models list response
#[derive(Serialize)]
struct ModelsListResponse {
object: String,
data: Vec<ModelObject>,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
/// Convert WhichModel to a display-friendly model ID (kebab-case)
fn which_model_to_id(which_model: WhichModel) -> &'static str {
match which_model {
WhichModel::MiniCPM4_0_5B => "minicpm4-0.5b",
WhichModel::Qwen2_5vl3B => "qwen2.5vl-3b",
WhichModel::Qwen2_5vl7B => "qwen2.5vl-7b",
WhichModel::Qwen3_0_6B => "qwen3-0.6b",
WhichModel::Qwen3ASR0_6B => "qwen3asr-0.6b",
WhichModel::Qwen3ASR1_7B => "qwen3asr-1.7b",
WhichModel::Qwen3vl2B => "qwen3vl-2b",
WhichModel::Qwen3vl4B => "qwen3vl-4b",
WhichModel::Qwen3vl8B => "qwen3vl-8b",
WhichModel::Qwen3vl32B => "qwen3vl-32b",
WhichModel::DeepSeekOCR => "deepseek-ocr",
WhichModel::HunyuanOCR => "hunyuan-ocr",
WhichModel::PaddleOCRVL => "paddleocr-vl",
WhichModel::RMBG2_0 => "rmbg2.0",
WhichModel::VoxCPM => "voxcpm",
WhichModel::VoxCPM1_5 => "voxcpm1.5",
WhichModel::GlmASRNano2512 => "glm-asr-nano-2512",
WhichModel::FunASRNano2512 => "fun-asr-nano-2512",
}
}
/// Get the owner/organization name for a model
fn which_model_to_owner(which_model: WhichModel) -> &'static str {
match which_model {
WhichModel::MiniCPM4_0_5B => "OpenBMB",
WhichModel::Qwen2_5vl3B | WhichModel::Qwen2_5vl7B => "Qwen",
WhichModel::Qwen3_0_6B | WhichModel::Qwen3ASR0_6B | WhichModel::Qwen3ASR1_7B => "Qwen",
WhichModel::Qwen3vl2B
| WhichModel::Qwen3vl4B
| WhichModel::Qwen3vl8B
| WhichModel::Qwen3vl32B => "Qwen",
WhichModel::DeepSeekOCR => "deepseek-ai",
WhichModel::HunyuanOCR => "Tencent-Hunyuan",
WhichModel::PaddleOCRVL => "PaddlePaddle",
WhichModel::RMBG2_0 => "AI-ModelScope",
WhichModel::VoxCPM | WhichModel::VoxCPM1_5 => "OpenBMB",
WhichModel::GlmASRNano2512 => "ZhipuAI",
WhichModel::FunASRNano2512 => "FunAudioLLM",
}
}
#[get("/models")]
pub(crate) async fn models() -> (Status, (ContentType, Json<serde_json::Value>)) {
if let Some(model_ref) = MODEL.get() {
let guard = model_ref.read().await;
let which_model = guard.which_model;
let model_obj = ModelObject {
id: which_model_to_id(which_model).to_string(),
object: "model".to_string(),
created: None, // We don't track creation time
owned_by: which_model_to_owner(which_model).to_string(),
};
drop(guard);
let response = ModelsListResponse {
object: "list".to_string(),
data: vec![model_obj],
};
(
Status::Ok,
(
ContentType::JSON,
Json(serde_json::to_value(response).unwrap()),
),
)
} else {
let response = ErrorResponse {
error: "model not initialized".to_string(),
};
(
Status::ServiceUnavailable,
(
ContentType::JSON,
Json(serde_json::to_value(response).unwrap()),
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
// Test health endpoint when model is not initialized
#[tokio::test]
async fn test_health_endpoint_uninitialized() {
let (status, (content_type, response)) = health().await;
assert_eq!(status, Status::ServiceUnavailable);
assert_eq!(content_type, ContentType::JSON);
assert_eq!(response.status, "unhealthy");
assert_eq!(response.error, Some("model not initialized".to_string()));
}
// Test health endpoint when model is initialized
// Note: This test requires a model to be initialized, which may not be feasible
// in unit tests without access to model files. This is a placeholder for integration tests.
//
// #[tokio::test]
// async fn test_health_endpoint_initialized() {
// // This would require model initialization
// // Consider moving to integration tests
// }
// Test models endpoint when model is not initialized
#[tokio::test]
async fn test_models_endpoint_uninitialized() {
let (status, (content_type, response)) = models().await;
assert_eq!(status, Status::ServiceUnavailable);
assert_eq!(content_type, ContentType::JSON);
let error = response.get("error").and_then(|v| v.as_str());
assert_eq!(error, Some("model not initialized"));
}
// Test model type classification
#[test]
fn test_get_model_type_llm() {
assert_eq!(WhichModel::Qwen3_0_6B.model_type(), "llm");
assert_eq!(WhichModel::Qwen3vl2B.model_type(), "llm");
assert_eq!(WhichModel::MiniCPM4_0_5B.model_type(), "llm");
assert_eq!(WhichModel::Qwen2_5vl3B.model_type(), "llm");
assert_eq!(WhichModel::Qwen2_5vl7B.model_type(), "llm");
assert_eq!(WhichModel::Qwen3vl4B.model_type(), "llm");
assert_eq!(WhichModel::Qwen3vl8B.model_type(), "llm");
assert_eq!(WhichModel::Qwen3vl32B.model_type(), "llm");
}
#[test]
fn test_get_model_type_ocr() {
assert_eq!(WhichModel::DeepSeekOCR.model_type(), "ocr");
assert_eq!(WhichModel::HunyuanOCR.model_type(), "ocr");
assert_eq!(WhichModel::PaddleOCRVL.model_type(), "ocr");
}
#[test]
fn test_get_model_type_asr() {
assert_eq!(WhichModel::Qwen3ASR0_6B.model_type(), "asr");
assert_eq!(WhichModel::Qwen3ASR1_7B.model_type(), "asr");
assert_eq!(WhichModel::GlmASRNano2512.model_type(), "asr");
assert_eq!(WhichModel::FunASRNano2512.model_type(), "asr");
}
#[test]
fn test_get_model_type_image() {
assert_eq!(WhichModel::RMBG2_0.model_type(), "image");
assert_eq!(WhichModel::VoxCPM.model_type(), "image");
assert_eq!(WhichModel::VoxCPM1_5.model_type(), "image");
}
// Test model_id retrieval
#[test]
fn test_get_model_id() {
assert_eq!(WhichModel::Qwen3_0_6B.model_id(), "Qwen/Qwen3-0.6B");
assert_eq!(
WhichModel::DeepSeekOCR.model_id(),
"deepseek-ai/DeepSeek-OCR"
);
assert_eq!(WhichModel::VoxCPM1_5.model_id(), "OpenBMB/VoxCPM1.5");
}
// Test OpenAI-compatible model ID conversion
#[test]
fn test_which_model_to_id() {
assert_eq!(which_model_to_id(WhichModel::Qwen3_0_6B), "qwen3-0.6b");
assert_eq!(which_model_to_id(WhichModel::DeepSeekOCR), "deepseek-ocr");
assert_eq!(which_model_to_id(WhichModel::VoxCPM1_5), "voxcpm1.5");
assert_eq!(
which_model_to_id(WhichModel::MiniCPM4_0_5B),
"minicpm4-0.5b"
);
}
// Test owner/organization mapping
#[test]
fn test_which_model_to_owner() {
assert_eq!(which_model_to_owner(WhichModel::Qwen3_0_6B), "Qwen");
assert_eq!(which_model_to_owner(WhichModel::DeepSeekOCR), "deepseek-ai");
assert_eq!(which_model_to_owner(WhichModel::VoxCPM1_5), "OpenBMB");
assert_eq!(
which_model_to_owner(WhichModel::HunyuanOCR),
"Tencent-Hunyuan"
);
}
}
// Shutdown endpoint
#[derive(Serialize)]
struct ShutdownResponse {
message: String,
}
#[post("/shutdown")]
pub(crate) async fn shutdown(
shutdown_flag: &State<Arc<AtomicBool>>,
) -> (Status, (ContentType, Json<serde_json::Value>)) {
// Check if remote shutdown is allowed
let allow_remote = ALLOW_REMOTE_SHUTDOWN.get().copied().unwrap_or(false);
// Log the shutdown request
eprintln!(
"[SHUTDOWN] Shutdown requested (remote_allowed: {})",
allow_remote
);
// Note: Rocket 0.5 doesn't provide easy access to client IP in request guards
// For proper IP-based filtering, you would need to use custom request guards
// or middleware. For now, we rely on the --allow-remote-shutdown flag.
shutdown_flag.store(true, Ordering::SeqCst);
// Cleanup PID file in a background task
if let Some(&port) = SERVER_PORT.get() {
let _ = cleanup_pid_file(port);
}
// Schedule shutdown after a short delay to allow response to be sent
let _flag = shutdown_flag.inner().clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
std::process::exit(0);
});
let response = ShutdownResponse {
message: "Shutting down...".to_string(),
};
(
Status::Ok,
(
ContentType::JSON,
Json(serde_json::to_value(response).unwrap()),
),
)
}
+1
View File
@@ -2,5 +2,6 @@ pub mod chat_template;
pub mod exec;
pub mod models;
pub mod position_embed;
pub mod process;
pub mod tokenizer;
pub mod utils;
+251 -45
View File
@@ -1,7 +1,9 @@
use std::{net::IpAddr, str::FromStr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::{net::IpAddr, str::FromStr, sync::Arc};
use aha::{
models::WhichModel,
process::{cleanup_pid_file, create_pid_file},
utils::{download_model, get_default_save_dir},
};
use clap::{Args, Parser, Subcommand, ValueEnum};
@@ -10,8 +12,9 @@ use rocket::{
data::{ByteUnit, Limits},
routes,
};
use serde::Serialize;
use crate::api::init;
use crate::api::{init, set_server_port};
mod api;
#[derive(Parser, Debug)]
@@ -52,12 +55,16 @@ enum Commands {
Cli(CliArgs),
/// Start service only (--weight-path is optional, defaults to ~/.aha/{model_id})
Serv(ServArgs),
/// List all running aha services
Ps(ServListArgs),
/// Delete a downloaded model from the default location (~/.aha/{model_id})
Delete(DeleteArgs),
/// Download model only
Download(DownloadArgs),
/// Run model inference directly
Run(RunArgs),
/// List all supported models
List,
List(ListArgs),
}
/// Common/shared arguments for server operations
@@ -74,6 +81,10 @@ struct CommonArgs {
/// Model type (required)
#[arg(short, long)]
model: WhichModel,
/// Allow remote shutdown requests (default: local only, use with caution)
#[arg(long)]
allow_remote_shutdown: bool,
}
/// Arguments for the 'cli' subcommand (download + serve)
@@ -95,7 +106,7 @@ struct CliArgs {
download_retries: Option<u32>,
}
/// Arguments for the 'serv' subcommand (serve only)
/// Arguments for the 'serv start' subcommand
#[derive(Args, Debug)]
struct ServArgs {
#[command(flatten)]
@@ -106,6 +117,14 @@ struct ServArgs {
weight_path: Option<String>,
}
/// Arguments for the 'serv list' subcommand
#[derive(Args, Debug)]
struct ServListArgs {
/// Compact output format
#[arg(short, long)]
compact: bool,
}
/// Arguments for the 'download' subcommand (download only)
#[derive(Args, Debug)]
struct DownloadArgs {
@@ -142,40 +161,41 @@ struct RunArgs {
weight_path: Option<String>,
}
/// Arguments for the 'delete' subcommand (delete model from default location)
#[derive(Args, Debug)]
struct DeleteArgs {
/// Model type (required)
#[arg(short, long)]
model: WhichModel,
}
/// Arguments for the 'list' subcommand (list all supported models)
#[derive(Args, Debug)]
struct ListArgs {
/// Output models in JSON format (includes name, model_id, and type fields)
#[arg(short, long)]
json: bool,
}
/// Get the default weight path for a given model
/// Returns ~/.aha/{model_id} e.g., ~/.aha/OpenBMB/VoxCPM1.5
fn get_default_weight_path(model: WhichModel) -> String {
let model_id = get_model_id(model);
let model_id = model.model_id();
let save_dir = get_default_save_dir().expect("Failed to get home directory");
format!("{}/{}", save_dir, model_id)
}
/// Get the ModelScope model ID for a given WhichModel variant
fn get_model_id(model: WhichModel) -> &'static str {
match model {
WhichModel::MiniCPM4_0_5B => "OpenBMB/MiniCPM4-0.5B",
WhichModel::Qwen2_5vl3B => "Qwen/Qwen2.5-VL-3B-Instruct",
WhichModel::Qwen2_5vl7B => "Qwen/Qwen2.5-VL-7B-Instruct",
WhichModel::Qwen3_0_6B => "Qwen/Qwen3-0.6B",
WhichModel::Qwen3ASR0_6B => "Qwen/Qwen3-ASR-0.6B",
WhichModel::Qwen3ASR1_7B => "Qwen/Qwen3-ASR-1.7B",
WhichModel::Qwen3vl2B => "Qwen/Qwen3-VL-2B-Instruct",
WhichModel::Qwen3vl4B => "Qwen/Qwen3-VL-4B-Instruct",
WhichModel::Qwen3vl8B => "Qwen/Qwen3-VL-8B-Instruct",
WhichModel::Qwen3vl32B => "Qwen/Qwen3-VL-32B-Instruct",
WhichModel::DeepSeekOCR => "deepseek-ai/DeepSeek-OCR",
WhichModel::HunyuanOCR => "Tencent-Hunyuan/HunyuanOCR",
WhichModel::PaddleOCRVL => "PaddlePaddle/PaddleOCR-VL",
WhichModel::RMBG2_0 => "AI-ModelScope/RMBG-2.0",
WhichModel::VoxCPM => "OpenBMB/VoxCPM-0.5B",
WhichModel::VoxCPM1_5 => "OpenBMB/VoxCPM1.5",
WhichModel::GlmASRNano2512 => "ZhipuAI/GLM-ASR-Nano-2512",
WhichModel::FunASRNano2512 => "FunAudioLLM/Fun-ASR-Nano-2512",
}
/// Model information for JSON output
#[derive(Serialize)]
struct ModelInfo {
name: String,
model_id: String,
#[serde(rename = "type")]
model_type: String,
}
/// List all supported models
fn run_list() -> anyhow::Result<()> {
fn run_list(args: ListArgs) -> anyhow::Result<()> {
let models = [
WhichModel::MiniCPM4_0_5B,
WhichModel::Qwen2_5vl3B,
@@ -197,15 +217,32 @@ fn run_list() -> anyhow::Result<()> {
WhichModel::FunASRNano2512,
];
println!("Available models:");
println!();
println!("{:<30} ModelScope ID", "Model Name");
println!("{}", "-".repeat(80));
for model in models {
let possible_value = model.to_possible_value().unwrap();
let name = possible_value.get_name();
let id = get_model_id(model);
println!("{:<30} {}", name, id);
if args.json {
// JSON output
let model_infos: Vec<ModelInfo> = models
.iter()
.map(|model| {
let possible_value = model.to_possible_value().unwrap();
ModelInfo {
name: possible_value.get_name().to_string(),
model_id: model.model_id().to_string(),
model_type: model.model_type().to_string(),
}
})
.collect();
println!("{}", serde_json::to_string_pretty(&model_infos)?);
} else {
// Table output (default)
println!("Available models:");
println!();
println!("{:<30} ModelScope ID", "Model Name");
println!("{}", "-".repeat(80));
for model in models {
let possible_value = model.to_possible_value().unwrap();
let name = possible_value.get_name();
let id = model.model_id();
println!("{:<30} {}", name, id);
}
}
Ok(())
@@ -219,7 +256,7 @@ async fn run_cli(args: CliArgs) -> anyhow::Result<()> {
save_dir,
download_retries,
} = args;
let model_id = get_model_id(common.model);
let model_id = common.model.model_id();
let model_path = match weight_path {
Some(path) => path,
@@ -235,7 +272,7 @@ async fn run_cli(args: CliArgs) -> anyhow::Result<()> {
};
init(common.model, model_path)?;
start_http_server(common.address, common.port).await?;
start_http_server(common.address, common.port, common.allow_remote_shutdown).await?;
Ok(())
}
@@ -253,7 +290,48 @@ async fn run_serv(args: ServArgs) -> anyhow::Result<()> {
};
init(common.model, model_path)?;
start_http_server(common.address, common.port).await?;
start_http_server(common.address, common.port, common.allow_remote_shutdown).await?;
Ok(())
}
/// Run the 'ps' subcommand: list running AHA services
fn run_ps(args: ServListArgs) -> anyhow::Result<()> {
use aha::process::find_aha_services;
let services = find_aha_services()?;
if services.is_empty() {
println!("No aha services found running.");
return Ok(());
}
if args.compact {
// Compact format: one service per line
for svc in services {
println!("{}", svc.service_id);
}
} else {
// Table format
println!(
"{:<20} {:<10} {:<20} {:<10} {:<15} {:<10}",
"Service ID", "PID", "Model", "Port", "Address", "Status"
);
println!("{}", "-".repeat(85));
for svc in services {
let model = svc.model.as_deref().unwrap_or("N/A");
let status = match svc.status {
aha::process::ServiceStatus::Running => "Running",
aha::process::ServiceStatus::Stopping => "Stopping",
aha::process::ServiceStatus::Unknown => "Unknown",
};
println!(
"{:<20} {:<10} {:<20} {:<10} {:<15} {:<10}",
svc.service_id, svc.pid, model, svc.port, svc.address, status,
);
}
}
Ok(())
}
@@ -265,7 +343,7 @@ async fn run_download(args: DownloadArgs) -> anyhow::Result<()> {
save_dir,
download_retries,
} = args;
let model_id = get_model_id(model);
let model_id = model.model_id();
let save_dir = match save_dir {
Some(dir) => dir,
@@ -373,6 +451,93 @@ fn run_run(args: RunArgs) -> anyhow::Result<()> {
Ok(())
}
/// Run the 'delete' subcommand: delete model from default location
fn run_delete(args: DeleteArgs) -> anyhow::Result<()> {
let DeleteArgs { model } = args;
let model_id = model.model_id();
let save_dir = get_default_save_dir().expect("Failed to get home directory");
let model_path = format!("{}/{}", save_dir, model_id);
let path = std::path::Path::new(&model_path);
if !path.exists() {
println!("Model not found: {} does not exist", model_path);
return Ok(());
}
// Show model info
println!("Model ID: {}", model_id);
println!("Location: {}", model_path);
// Calculate size if possible
if let Ok(metadata) = std::fs::metadata(path)
&& metadata.is_dir()
&& let Ok(total_size) = dir_size(path)
{
println!("Size: {}", bytes_to_human(total_size));
}
// Confirm deletion
print!("Are you sure you want to delete this model? (y/N): ");
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input != "y" && input != "yes" {
println!("Deletion cancelled.");
return Ok(());
}
// Delete the directory
std::fs::remove_dir_all(path)?;
println!("Model deleted successfully: {}", model_path);
Ok(())
}
/// Calculate total size of a directory recursively
fn dir_size(path: &std::path::Path) -> anyhow::Result<u64> {
let mut total = 0;
if path.is_dir() {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_dir() {
total += dir_size(&entry_path)?;
} else {
total += entry.metadata()?.len();
}
}
} else {
total = std::fs::metadata(path)?.len();
}
Ok(total)
}
/// Convert bytes to human readable format
fn bytes_to_human(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.2} TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
@@ -380,9 +545,11 @@ async fn main() -> anyhow::Result<()> {
match cli.command {
Some(Commands::Cli(args)) => run_cli(args).await,
Some(Commands::Serv(args)) => run_serv(args).await,
Some(Commands::Ps(args)) => run_ps(args),
Some(Commands::Delete(args)) => run_delete(args),
Some(Commands::Download(args)) => run_download(args).await,
Some(Commands::Run(args)) => run_run(args),
Some(Commands::List) => run_list(),
Some(Commands::List(args)) => run_list(args),
None => {
// Backward compatibility: when no subcommand is provided, use 'cli' behavior
let model = cli.model.expect("Model is required (use -m or --model)");
@@ -391,6 +558,7 @@ async fn main() -> anyhow::Result<()> {
address: cli.address.unwrap_or_else(|| "127.0.0.1".to_string()),
port: cli.port.unwrap_or(10100),
model,
allow_remote_shutdown: false,
},
weight_path: cli.weight_path,
save_dir: cli.save_dir,
@@ -401,7 +569,35 @@ async fn main() -> anyhow::Result<()> {
}
}
pub(crate) async fn start_http_server(address: String, port: u16) -> anyhow::Result<()> {
pub(crate) async fn start_http_server(
address: String,
port: u16,
allow_remote_shutdown: bool,
) -> anyhow::Result<()> {
// Set server port for shutdown endpoint
set_server_port(port, allow_remote_shutdown);
// Create PID file for service tracking
let pid = std::process::id();
create_pid_file(pid, port)?;
// Set up shutdown flag
let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_flag_clone = shutdown_flag.clone();
// Configure Ctrl+C handler for graceful shutdown
let port_for_cleanup = port;
let shutdown_handler = tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
println!("Received shutdown signal, gracefully shutting down...");
shutdown_flag_clone.store(true, Ordering::SeqCst);
// Give time for existing requests to complete
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Cleanup PID file
let _ = cleanup_pid_file(port_for_cleanup);
std::process::exit(0);
});
let mut builder = rocket::build().configure(Config {
address: IpAddr::from_str(&address)?,
port,
@@ -416,9 +612,19 @@ pub(crate) async fn start_http_server(address: String, port: u16) -> anyhow::Res
builder = builder.mount("/chat", routes![api::chat]);
// /images/remove_background
builder = builder.mount("/images", routes![api::remove_background]);
// /images/speech
// /audio/speech
builder = builder.mount("/audio", routes![api::speech]);
// Health check and model info endpoints
builder = builder.mount("/", routes![api::health, api::models]);
// Shutdown endpoint
builder = builder.manage(shutdown_flag);
builder = builder.mount("/", routes![api::shutdown]);
let _rocket = builder.launch().await?;
// Cleanup PID file when server exits
cleanup_pid_file(port)?;
shutdown_handler.abort();
builder.launch().await?;
Ok(())
}
+50
View File
@@ -74,6 +74,56 @@ pub enum WhichModel {
FunASRNano2512,
}
impl WhichModel {
/// Get the ModelScope model ID for this model variant
pub fn model_id(self) -> &'static str {
match self {
WhichModel::MiniCPM4_0_5B => "OpenBMB/MiniCPM4-0.5B",
WhichModel::Qwen2_5vl3B => "Qwen/Qwen2.5-VL-3B-Instruct",
WhichModel::Qwen2_5vl7B => "Qwen/Qwen2.5-VL-7B-Instruct",
WhichModel::Qwen3_0_6B => "Qwen/Qwen3-0.6B",
WhichModel::Qwen3ASR0_6B => "Qwen/Qwen3-ASR-0.6B",
WhichModel::Qwen3ASR1_7B => "Qwen/Qwen3-ASR-1.7B",
WhichModel::Qwen3vl2B => "Qwen/Qwen3-VL-2B-Instruct",
WhichModel::Qwen3vl4B => "Qwen/Qwen3-VL-4B-Instruct",
WhichModel::Qwen3vl8B => "Qwen/Qwen3-VL-8B-Instruct",
WhichModel::Qwen3vl32B => "Qwen/Qwen3-VL-32B-Instruct",
WhichModel::DeepSeekOCR => "deepseek-ai/DeepSeek-OCR",
WhichModel::HunyuanOCR => "Tencent-Hunyuan/HunyuanOCR",
WhichModel::PaddleOCRVL => "PaddlePaddle/PaddleOCR-VL",
WhichModel::RMBG2_0 => "AI-ModelScope/RMBG-2.0",
WhichModel::VoxCPM => "OpenBMB/VoxCPM-0.5B",
WhichModel::VoxCPM1_5 => "OpenBMB/VoxCPM1.5",
WhichModel::GlmASRNano2512 => "ZhipuAI/GLM-ASR-Nano-2512",
WhichModel::FunASRNano2512 => "FunAudioLLM/Fun-ASR-Nano-2512",
}
}
/// Get the model type category for this model variant
pub fn model_type(self) -> &'static str {
match self {
// LLM models
WhichModel::MiniCPM4_0_5B
| WhichModel::Qwen2_5vl3B
| WhichModel::Qwen2_5vl7B
| WhichModel::Qwen3_0_6B
| WhichModel::Qwen3vl2B
| WhichModel::Qwen3vl4B
| WhichModel::Qwen3vl8B
| WhichModel::Qwen3vl32B => "llm",
// OCR models
WhichModel::DeepSeekOCR | WhichModel::HunyuanOCR | WhichModel::PaddleOCRVL => "ocr",
// ASR models
WhichModel::Qwen3ASR0_6B
| WhichModel::Qwen3ASR1_7B
| WhichModel::GlmASRNano2512
| WhichModel::FunASRNano2512 => "asr",
// Image models
WhichModel::RMBG2_0 | WhichModel::VoxCPM | WhichModel::VoxCPM1_5 => "image",
}
}
}
pub trait GenerateModel {
fn generate(&mut self, mes: ChatCompletionParameters) -> Result<ChatCompletionResponse>;
fn generate_stream(
+288
View File
@@ -0,0 +1,288 @@
//! Process management module for AHA services
//!
//! This module provides functionality for:
//! - Managing PID files for service tracking
//! - Discovering running AHA services
//! - Service information display
use std::fs;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use sysinfo::{Pid, ProcessesToUpdate, System};
/// Service information structure
#[derive(Debug, Clone)]
pub struct ServiceInfo {
/// Service unique identifier (format: pid@port)
pub service_id: String,
/// Process ID
pub pid: u32,
/// Model name (if available)
pub model: Option<String>,
/// Listen port
pub port: u16,
/// Listen address
pub address: String,
/// Service status
pub status: ServiceStatus,
}
/// Service status
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Running,
Stopping,
Unknown,
}
/// Get the PID file directory
///
/// Returns the appropriate directory for storing PID files:
/// - Linux/macOS: $XDG_RUNTIME_DIR/aha or ~/.aha/run
/// - Windows: %LOCALAPPDATA%\aha\run
pub fn get_pid_dir() -> Result<PathBuf> {
#[cfg(unix)]
{
// Try XDG_RUNTIME_DIR first
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
let pid_dir = PathBuf::from(runtime_dir).join("aha");
fs::create_dir_all(&pid_dir)?;
return Ok(pid_dir);
}
// Fallback to ~/.aha/run
let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
let pid_dir = home.join(".aha").join("run");
fs::create_dir_all(&pid_dir)?;
Ok(pid_dir)
}
#[cfg(windows)]
{
let local_app_data = std::env::var("LOCALAPPDATA")
.map_err(|_| anyhow!("Cannot determine LOCALAPPDATA directory"))?;
let pid_dir = PathBuf::from(local_app_data).join("aha").join("run");
fs::create_dir_all(&pid_dir)?;
Ok(pid_dir)
}
}
/// Create a PID file for the current service
///
/// # Arguments
/// * `pid` - Process ID
/// * `port` - Listen port
pub fn create_pid_file(pid: u32, port: u16) -> Result<()> {
let pid_dir = get_pid_dir()?;
let pid_file = pid_dir.join(format!("{}.pid", port));
let content = format!("{}\n", pid);
fs::write(&pid_file, content)?;
Ok(())
}
/// Clean up a PID file
///
/// # Arguments
/// * `port` - Listen port
pub fn cleanup_pid_file(port: u16) -> Result<()> {
let pid_dir = get_pid_dir()?;
let pid_file = pid_dir.join(format!("{}.pid", port));
if pid_file.exists() {
fs::remove_file(&pid_file)?;
}
Ok(())
}
/// Get the PID from a PID file
///
/// # Arguments
/// * `port` - Listen port
pub fn get_pid_from_file(port: u16) -> Option<u32> {
let pid_dir = get_pid_dir().ok()?;
let pid_file = pid_dir.join(format!("{}.pid", port));
if !pid_file.exists() {
return None;
}
let content = fs::read_to_string(&pid_file).ok()?;
content.trim().parse::<u32>().ok()
}
/// Check if a process is an AHA service
///
/// Verifies that the process command line contains "aha serv" or "aha cli"
fn is_aha_process(sys: &System, pid: Pid) -> bool {
if let Some(process) = sys.process(pid) {
let cmd = process.cmd();
let cmd_str: String = cmd
.iter()
.filter_map(|s| s.to_str())
.collect::<Vec<&str>>()
.join(" ");
return cmd_str.contains("aha serv") || cmd_str.contains("aha cli");
}
false
}
/// Find all running AHA services
///
/// Returns a list of ServiceInfo for all running AHA services
pub fn find_aha_services() -> Result<Vec<ServiceInfo>> {
let mut services = Vec::new();
let mut sys = System::new_all();
sys.refresh_processes(ProcessesToUpdate::All, true);
// First, try to discover services from PID files
let pid_dir = get_pid_dir()?;
if let Ok(entries) = fs::read_dir(&pid_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("pid") {
continue;
}
// Extract port from filename
let port_str = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let port: u16 = port_str.parse().unwrap_or(0);
if port == 0 {
continue;
}
// Read PID from file
if let Ok(content) = fs::read_to_string(&path)
&& let Ok(pid) = content.trim().parse::<u32>()
{
let sys_pid = Pid::from_u32(pid);
if is_aha_process(&sys, sys_pid) {
services.push(ServiceInfo {
service_id: format!("{}@{}", pid, port),
pid,
model: None, // TODO: Extract from command line
port,
address: "127.0.0.1".to_string(),
status: ServiceStatus::Running,
});
} else {
// Stale PID file, remove it
let _ = fs::remove_file(&path);
}
}
}
}
// Fallback: scan processes for AHA services
for (pid, process) in sys.processes() {
if services.iter().any(|s| s.pid == pid.as_u32()) {
continue; // Already found via PID file
}
let cmd = process.cmd();
let cmd_str: String = cmd
.iter()
.filter_map(|s| s.to_str())
.collect::<Vec<&str>>()
.join(" ");
if cmd_str.contains("aha serv") || cmd_str.contains("aha cli") {
// Try to extract port from command line
let port_str = cmd
.iter()
.position(|s| s.to_str() == Some("--port"))
.and_then(|i| cmd.get(i + 1))
.and_then(|s| s.to_str());
let port = port_str
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(10100);
services.push(ServiceInfo {
service_id: format!("{}@{}", pid.as_u32(), port),
pid: pid.as_u32(),
model: None,
port,
address: "127.0.0.1".to_string(),
status: ServiceStatus::Running,
});
}
}
Ok(services)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_pid_dir() {
let pid_dir = get_pid_dir();
assert!(pid_dir.is_ok());
let dir = pid_dir.unwrap();
assert!(dir.exists());
}
#[test]
fn test_create_and_cleanup_pid_file() {
let port = 19999;
create_pid_file(12345, port).unwrap();
let pid = get_pid_from_file(port);
assert_eq!(pid, Some(12345));
cleanup_pid_file(port).unwrap();
let pid = get_pid_from_file(port);
assert_eq!(pid, None);
}
#[test]
fn test_get_pid_from_file_nonexistent() {
let port = 19998; // Use a port that likely doesn't have a PID file
let pid = get_pid_from_file(port);
assert_eq!(pid, None);
}
#[test]
fn test_service_status_debug() {
// Test ServiceStatus Debug implementation
assert_eq!(format!("{:?}", ServiceStatus::Running), "Running");
assert_eq!(format!("{:?}", ServiceStatus::Stopping), "Stopping");
assert_eq!(format!("{:?}", ServiceStatus::Unknown), "Unknown");
}
#[test]
fn test_service_info_clone() {
let service = ServiceInfo {
service_id: "12345@10100".to_string(),
pid: 12345,
model: Some("qwen3-0.6b".to_string()),
port: 10100,
address: "127.0.0.1".to_string(),
status: ServiceStatus::Running,
};
let service_clone = service.clone();
assert_eq!(service_clone.service_id, "12345@10100");
assert_eq!(service_clone.pid, 12345);
assert_eq!(service_clone.model, Some("qwen3-0.6b".to_string()));
assert_eq!(service_clone.port, 10100);
}
#[test]
fn test_find_aha_services() {
// This test will find actual running AHA services or return empty
let services = find_aha_services();
assert!(services.is_ok());
let services_list = services.unwrap();
// We can't assert specific services here since it depends on what's running
// but we can verify the structure is correct
for service in services_list {
assert!(!service.service_id.is_empty());
assert!(service.pid > 0);
assert!(service.port > 0);
assert!(!service.address.is_empty());
}
}
}