Files
ForgeLM/crates/localllm-cli/src/main.rs
T

704 lines
19 KiB
Rust

use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum AuthMode {
Off,
LocalOnly,
Protected,
}
#[derive(Debug, Parser)]
#[command(name = "localllm", about = "Manage the local LLM backend")]
struct Cli {
#[arg(long, global = true, default_value = "http://127.0.0.1:8080")]
server: String,
#[arg(long, global = true)]
api_key: Option<String>,
#[arg(long, global = true, default_value = "./models")]
models_dir: String,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Status,
Models(ModelsCommand),
Auth(AuthCommand),
Config(ConfigCommand),
Bench(BenchCommand),
Logs(LogsArgs),
Doctor,
}
#[derive(Debug, Args)]
struct ModelsCommand {
#[command(subcommand)]
command: ModelsSubcommand,
}
#[derive(Debug, Subcommand)]
enum ModelsSubcommand {
List,
Scan,
Load(ModelsLoadArgs),
Unload(ModelsUnloadArgs),
Use(ModelsUseArgs),
Watch,
Download(ModelsDownloadArgs),
Remove(ModelsRemoveArgs),
}
#[derive(Debug, Args)]
struct ModelsLoadArgs {
model: String,
#[arg(long)]
alias: Option<String>,
}
#[derive(Debug, Args)]
struct ModelsUnloadArgs {
model: Option<String>,
}
#[derive(Debug, Args)]
struct ModelsUseArgs {
model: String,
}
#[derive(Debug, Args)]
struct ModelsDownloadArgs {
repo: String,
}
#[derive(Debug, Args)]
struct ModelsRemoveArgs {
model: String,
}
#[derive(Debug, Args)]
struct AuthCommand {
#[command(subcommand)]
command: AuthSubcommand,
}
#[derive(Debug, Subcommand)]
enum AuthSubcommand {
Status,
Enable(AuthEnableArgs),
Disable,
SetKey(AuthSetKeyArgs),
}
#[derive(Debug, Args)]
struct AuthEnableArgs {
#[arg(long, value_enum, default_value_t = AuthMode::Protected)]
mode: AuthMode,
#[arg(long)]
api_key: Option<String>,
}
#[derive(Debug, Args)]
struct AuthSetKeyArgs {
api_key: String,
}
#[derive(Debug, Args)]
struct ConfigCommand {
#[command(subcommand)]
command: ConfigSubcommand,
}
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
Get(ConfigGetArgs),
Set(ConfigSetArgs),
}
#[derive(Debug, Args)]
struct ConfigGetArgs {
key: String,
}
#[derive(Debug, Args)]
struct ConfigSetArgs {
key: String,
value: String,
}
#[derive(Debug, Args)]
struct BenchCommand {
#[command(subcommand)]
command: BenchSubcommand,
}
#[derive(Debug, Subcommand)]
enum BenchSubcommand {
Throughput(BenchThroughputArgs),
Smoke,
Suite,
Compare(BenchCompareArgs),
}
#[derive(Debug, Args)]
struct BenchThroughputArgs {
#[arg(long, default_value_t = 3)]
samples: u32,
#[arg(long, default_value_t = 256)]
prompt_tokens: u32,
#[arg(long, default_value_t = 128)]
completion_tokens: u32,
}
#[derive(Debug, Args)]
struct BenchCompareArgs {
baseline: String,
candidate: String,
}
#[derive(Debug, Args)]
struct LogsArgs {
#[arg(long, default_value_t = 200)]
lines: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CommandPlan {
SummaryOnly {
command: String,
},
ProbeStatus {
server: String,
api_key_present: bool,
format: OutputFormat,
},
ScanModels {
models_dir: String,
},
LoadModel {
models_dir: String,
model: String,
alias: Option<String>,
},
ConfigureAuth {
mode: AuthMode,
api_key: Option<String>,
},
SetConfig {
key: String,
value: String,
},
RunThroughputBench {
server: String,
api_key_present: bool,
samples: u32,
prompt_tokens: u32,
completion_tokens: u32,
},
ReadLogs {
lines: u32,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
println!("CLI scaffold ready for {}", render_command_summary(&cli));
Ok(())
}
fn render_command_summary(cli: &Cli) -> String {
format!(
"command={} server={} format={} models_dir={}",
command_summary(&cli.command),
cli.server,
output_format_name(cli.format),
cli.models_dir
)
}
fn plan_command(cli: &Cli) -> CommandPlan {
match &cli.command {
Commands::Status => CommandPlan::ProbeStatus {
server: cli.server.clone(),
api_key_present: cli.api_key.is_some(),
format: cli.format,
},
Commands::Models(models) => match &models.command {
ModelsSubcommand::Scan => CommandPlan::ScanModels {
models_dir: cli.models_dir.clone(),
},
ModelsSubcommand::Load(args) => CommandPlan::LoadModel {
models_dir: cli.models_dir.clone(),
model: args.model.clone(),
alias: args.alias.clone(),
},
_ => CommandPlan::SummaryOnly {
command: command_summary(&cli.command),
},
},
Commands::Auth(auth) => match &auth.command {
AuthSubcommand::Enable(args) => CommandPlan::ConfigureAuth {
mode: args.mode,
api_key: args.api_key.clone(),
},
_ => CommandPlan::SummaryOnly {
command: command_summary(&cli.command),
},
},
Commands::Config(config) => match &config.command {
ConfigSubcommand::Set(args) => CommandPlan::SetConfig {
key: args.key.clone(),
value: args.value.clone(),
},
_ => CommandPlan::SummaryOnly {
command: command_summary(&cli.command),
},
},
Commands::Bench(bench) => match &bench.command {
BenchSubcommand::Throughput(args) => CommandPlan::RunThroughputBench {
server: cli.server.clone(),
api_key_present: cli.api_key.is_some(),
samples: args.samples,
prompt_tokens: args.prompt_tokens,
completion_tokens: args.completion_tokens,
},
_ => CommandPlan::SummaryOnly {
command: command_summary(&cli.command),
},
},
Commands::Logs(args) => CommandPlan::ReadLogs { lines: args.lines },
Commands::Doctor => CommandPlan::SummaryOnly {
command: command_summary(&cli.command),
},
}
}
fn command_summary(command: &Commands) -> String {
match command {
Commands::Status => "status".to_owned(),
Commands::Models(models) => models_command_summary(models),
Commands::Auth(auth) => auth_command_summary(auth),
Commands::Config(config) => config_command_summary(config),
Commands::Bench(bench) => bench_command_summary(bench),
Commands::Logs(args) => format!("logs lines={}", args.lines),
Commands::Doctor => "doctor".to_owned(),
}
}
fn output_format_name(format: OutputFormat) -> &'static str {
match format {
OutputFormat::Text => "text",
OutputFormat::Json => "json",
}
}
fn models_command_summary(command: &ModelsCommand) -> String {
match &command.command {
ModelsSubcommand::List => "models.list".to_owned(),
ModelsSubcommand::Scan => "models.scan".to_owned(),
ModelsSubcommand::Load(args) => match &args.alias {
Some(alias) => format!("models.load model={} alias={}", args.model, alias),
None => format!("models.load model={}", args.model),
},
ModelsSubcommand::Unload(args) => match &args.model {
Some(model) => format!("models.unload model={}", model),
None => "models.unload model=active".to_owned(),
},
ModelsSubcommand::Use(args) => format!("models.use model={}", args.model),
ModelsSubcommand::Watch => "models.watch".to_owned(),
ModelsSubcommand::Download(args) => format!("models.download repo={}", args.repo),
ModelsSubcommand::Remove(args) => format!("models.remove model={}", args.model),
}
}
fn auth_command_summary(command: &AuthCommand) -> String {
match &command.command {
AuthSubcommand::Status => "auth.status".to_owned(),
AuthSubcommand::Enable(args) => format!(
"auth.enable mode={} has_api_key={}",
auth_mode_name(args.mode),
args.api_key.is_some()
),
AuthSubcommand::Disable => "auth.disable".to_owned(),
AuthSubcommand::SetKey(_) => "auth.set-key has_api_key=true".to_owned(),
}
}
fn auth_mode_name(mode: AuthMode) -> &'static str {
match mode {
AuthMode::Off => "off",
AuthMode::LocalOnly => "local-only",
AuthMode::Protected => "protected",
}
}
fn config_command_summary(command: &ConfigCommand) -> String {
match &command.command {
ConfigSubcommand::Get(args) => format!("config.get key={}", args.key),
ConfigSubcommand::Set(args) => format!("config.set key={} value={}", args.key, args.value),
}
}
fn bench_command_summary(command: &BenchCommand) -> String {
match &command.command {
BenchSubcommand::Throughput(args) => format!(
"bench.throughput samples={} prompt_tokens={} completion_tokens={}",
args.samples, args.prompt_tokens, args.completion_tokens
),
BenchSubcommand::Smoke => "bench.smoke".to_owned(),
BenchSubcommand::Suite => "bench.suite".to_owned(),
BenchSubcommand::Compare(args) => {
format!(
"bench.compare baseline={} candidate={}",
args.baseline, args.candidate
)
}
}
}
#[cfg(test)]
mod tests {
use super::{plan_command, render_command_summary, AuthMode, Cli, CommandPlan, Commands};
use clap::Parser;
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_status_command_supports_global_output_flags() {
let parsed = Cli::try_parse_from(["localllm", "--format", "json", "status"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_list_command_is_available() {
let parsed = Cli::try_parse_from(["localllm", "models", "list"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_scan_command_is_available() {
let parsed = Cli::try_parse_from(["localllm", "models", "scan"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_load_command_accepts_model_and_alias() {
let parsed = Cli::try_parse_from([
"localllm",
"models",
"load",
"./models/qwen.gguf",
"--alias",
"qwen-coder",
]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_watch_command_is_available() {
let parsed = Cli::try_parse_from(["localllm", "models", "watch"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_download_command_accepts_hf_repo() {
let parsed = Cli::try_parse_from([
"localllm",
"models",
"download",
"Qwen/Qwen2.5-Coder-7B-Instruct-GGUF",
]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_auth_enable_command_supports_mode_and_key() {
let parsed = Cli::try_parse_from([
"localllm",
"auth",
"enable",
"--mode",
"protected",
"--api-key",
"secret",
]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_config_set_command_supports_key_value_updates() {
let parsed =
Cli::try_parse_from(["localllm", "config", "set", "default_model", "qwen-coder"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_bench_throughput_command_accepts_sample_controls() {
let parsed = Cli::try_parse_from([
"localllm",
"bench",
"throughput",
"--samples",
"5",
"--prompt-tokens",
"256",
"--completion-tokens",
"128",
]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_logs_command_is_available() {
let parsed = Cli::try_parse_from(["localllm", "logs"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_doctor_command_is_available() {
let parsed = Cli::try_parse_from(["localllm", "doctor"]);
assert!(parsed.is_ok());
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_status_summary_includes_output_contract() {
let cli = Cli::try_parse_from(["localllm", "--format", "json", "status"]).unwrap();
let summary = render_command_summary(&cli);
assert!(summary.contains("status"));
assert!(summary.contains("format=json"));
assert!(summary.contains("models_dir=./models"));
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_load_summary_includes_model_and_alias() {
let cli = Cli::try_parse_from([
"localllm",
"models",
"load",
"./models/qwen.gguf",
"--alias",
"qwen-coder",
])
.unwrap();
let summary = render_command_summary(&cli);
assert!(summary.contains("models.load"));
assert!(summary.contains("./models/qwen.gguf"));
assert!(summary.contains("alias=qwen-coder"));
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_auth_enable_summary_includes_mode_and_key_presence() {
let cli = Cli::try_parse_from([
"localllm",
"auth",
"enable",
"--mode",
"local-only",
"--api-key",
"secret",
])
.unwrap();
let summary = render_command_summary(&cli);
assert!(summary.contains("auth.enable"));
assert!(summary.contains("mode=local-only"));
assert!(summary.contains("has_api_key=true"));
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_bench_throughput_summary_includes_default_budget() {
let cli = Cli::try_parse_from(["localllm", "bench", "throughput"]).unwrap();
let summary = render_command_summary(&cli);
assert!(summary.contains("bench.throughput"));
assert!(summary.contains("samples=3"));
assert!(summary.contains("prompt_tokens=256"));
assert!(summary.contains("completion_tokens=128"));
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_logs_summary_includes_default_line_limit() {
let cli = Cli::try_parse_from(["localllm", "logs"]).unwrap();
let summary = render_command_summary(&cli);
assert!(summary.contains("logs"));
assert!(summary.contains("lines=200"));
}
#[test]
fn auth_mode_parser_accepts_local_only() {
let cli =
Cli::try_parse_from(["localllm", "auth", "enable", "--mode", "local-only"]).unwrap();
match cli.command {
Commands::Auth(auth) => match auth.command {
super::AuthSubcommand::Enable(args) => assert_eq!(args.mode, AuthMode::LocalOnly),
other => panic!("unexpected auth command: {:?}", other),
},
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_status_plan_keeps_server_and_format() {
let cli = Cli::try_parse_from(["localllm", "--format", "json", "status"]).unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::ProbeStatus {
server: "http://127.0.0.1:8080".to_owned(),
api_key_present: false,
format: super::OutputFormat::Json,
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_scan_plan_uses_models_dir() {
let cli = Cli::try_parse_from(["localllm", "--models-dir", "D:/models", "models", "scan"])
.unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::ScanModels {
models_dir: "D:/models".to_owned(),
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_models_load_plan_preserves_alias_and_models_dir() {
let cli = Cli::try_parse_from([
"localllm",
"--models-dir",
"D:/models",
"models",
"load",
"qwen.gguf",
"--alias",
"qwen-coder",
])
.unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::LoadModel {
models_dir: "D:/models".to_owned(),
model: "qwen.gguf".to_owned(),
alias: Some("qwen-coder".to_owned()),
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_auth_enable_plan_preserves_mode_and_key() {
let cli = Cli::try_parse_from([
"localllm",
"auth",
"enable",
"--mode",
"protected",
"--api-key",
"secret",
])
.unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::ConfigureAuth {
mode: AuthMode::Protected,
api_key: Some("secret".to_owned()),
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_config_set_plan_preserves_key_value() {
let cli = Cli::try_parse_from(["localllm", "config", "set", "default_model", "qwen-coder"])
.unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::SetConfig {
key: "default_model".to_owned(),
value: "qwen-coder".to_owned(),
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_bench_throughput_plan_preserves_workload() {
let cli = Cli::try_parse_from([
"localllm",
"--api-key",
"secret",
"bench",
"throughput",
"--samples",
"5",
"--prompt-tokens",
"512",
"--completion-tokens",
"64",
])
.unwrap();
assert_eq!(
plan_command(&cli),
CommandPlan::RunThroughputBench {
server: "http://127.0.0.1:8080".to_owned(),
api_key_present: true,
samples: 5,
prompt_tokens: 512,
completion_tokens: 64,
}
);
}
#[test]
#[ignore = "autoresearch benchmark"]
fn cli_surface_logs_plan_preserves_line_limit() {
let cli = Cli::try_parse_from(["localllm", "logs", "--lines", "50"]).unwrap();
assert_eq!(plan_command(&cli), CommandPlan::ReadLogs { lines: 50 });
}
}