use std::fs;
use std::io::{self, Write};
use agent_llm::{ChatMessageExt, LlmRequestExt, ResponseFormatExt};
use clap::{Parser, Subcommand};
use llm_client::{
create_llm_provider_from_resolved, create_model_catalog, provider_registry, resolve_config,
ChatMessage, LlmRequest, ReasoningEffort, ResolveInput, ResponseFormat, Tool, ToolChoice,
};
#[derive(Debug, Parser)]
#[command(
name = "llm-client-cli",
version,
about = "LLM Client CLI - Query LLM providers from the command line"
)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Query {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
protocol: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
api_key_env: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
stream: bool,
#[arg(long)]
schema: Option<String>,
#[arg(long)]
schema_file: Option<String>,
#[arg(long)]
tools: Option<String>,
#[arg(long)]
tools_file: Option<String>,
#[arg(long)]
tool_choice: Option<String>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long, value_parser = clap::value_parser!(ReasoningEffort))]
reasoning_effort: Option<ReasoningEffort>,
prompt: String,
},
Models {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
protocol: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
api_key_env: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
json: bool,
},
Test {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug)]
pub enum CliError {
Config(String),
Api(String),
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(msg) => write!(f, "config error: {}", msg),
Self::Api(msg) => write!(f, "api error: {}", msg),
}
}
}
impl std::error::Error for CliError {}
impl From<llm_client::ResolveError> for CliError {
fn from(err: llm_client::ResolveError) -> Self {
Self::Config(err.to_string())
}
}
impl From<llm_client::LlmError> for CliError {
fn from(err: llm_client::LlmError) -> Self {
Self::Api(err.to_string())
}
}
pub async fn run() -> Result<(), CliError> {
let cli = Cli::parse();
match cli.command {
Commands::Query {
provider,
protocol,
base_url,
api_key_env,
api_key,
model,
stream,
schema,
schema_file,
tools,
tools_file,
tool_choice,
temperature,
max_tokens,
reasoning_effort,
prompt,
} => {
run_query(QueryArgs {
provider,
protocol,
base_url,
api_key_env,
api_key,
model,
stream,
schema,
schema_file,
tools,
tools_file,
tool_choice,
temperature,
max_tokens,
reasoning_effort,
prompt,
})
.await
}
Commands::Models {
provider,
protocol,
base_url,
api_key_env,
api_key,
json,
} => {
run_models(ModelsArgs {
provider,
protocol,
base_url,
api_key_env,
api_key,
json,
})
.await
}
Commands::Test { provider, json } => run_test(provider, json).await,
}
}
struct QueryArgs {
provider: Option<String>,
protocol: Option<String>,
base_url: Option<String>,
api_key_env: Option<String>,
api_key: Option<String>,
model: Option<String>,
stream: bool,
schema: Option<String>,
schema_file: Option<String>,
tools: Option<String>,
tools_file: Option<String>,
tool_choice: Option<String>,
temperature: Option<f64>,
max_tokens: Option<usize>,
reasoning_effort: Option<ReasoningEffort>,
prompt: String,
}
struct ModelsArgs {
provider: Option<String>,
protocol: Option<String>,
base_url: Option<String>,
api_key_env: Option<String>,
api_key: Option<String>,
json: bool,
}
async fn run_query(args: QueryArgs) -> Result<(), CliError> {
let config = resolve_config(ResolveInput {
provider: args.provider,
protocol: args.protocol,
api_key: args.api_key,
api_key_env: args.api_key_env,
base_url: args.base_url,
})?;
let model = args
.model
.ok_or_else(|| CliError::Config("model is required for query".to_string()))?;
let mut request = LlmRequest::new(vec![ChatMessage::user(&args.prompt)]);
let schema_str = args.schema.or_else(|| {
args.schema_file.as_ref().and_then(|path| {
fs::read_to_string(path)
.map_err(|e| eprintln!("Warning: Failed to read schema file: {}", e))
.ok()
})
});
if let Some(schema_str) = schema_str {
let schema: serde_json::Value = serde_json::from_str(&schema_str)
.map_err(|e| CliError::Config(format!("Invalid JSON schema: {}", e)))?;
request = request.with_response_format(ResponseFormat::json_schema("response", schema));
}
let tools_str = args.tools.or_else(|| {
args.tools_file.as_ref().and_then(|path| {
fs::read_to_string(path)
.map_err(|e| eprintln!("Warning: Failed to read tools file: {}", e))
.ok()
})
});
if let Some(tools_str) = tools_str {
let tools: Vec<Tool> = serde_json::from_str(&tools_str)
.map_err(|e| CliError::Config(format!("Invalid tools JSON: {}", e)))?;
request = request.with_tools(tools);
}
if let Some(choice) = args.tool_choice {
let tool_choice = match choice.as_str() {
"auto" => ToolChoice::Auto,
"none" => ToolChoice::None,
"required" => ToolChoice::Required,
name => ToolChoice::Specific(name.to_string()),
};
request = request.with_tool_choice(tool_choice);
}
if let Some(temp) = args.temperature {
request = request.with_temperature(temp);
}
if let Some(tokens) = args.max_tokens {
request = request.with_max_tokens(tokens);
}
if let Some(effort) = args.reasoning_effort {
request = request.with_reasoning_effort(effort);
}
let provider = create_llm_provider_from_resolved(&config, model, None, None)
.map_err(|e| CliError::Api(e.to_string()))?;
if args.stream {
let response = provider
.complete_stream(&request, &|chunk| {
if let Some(ref text) = chunk.delta_text {
print!("{}", text);
io::stdout().flush().ok();
}
if let Some(ref tc) = chunk.delta_tool_call {
if !tc.tool_name.is_empty() {
eprintln!("[Tool Call] {}", tc.tool_name);
}
}
})
.await?;
println!();
if !response.message.tool_calls.is_empty() {
for tc in &response.message.tool_calls {
eprintln!("[Tool Call] {}({})", tc.tool_name, tc.input);
}
}
} else {
let response = provider.complete(&request).await?;
if !response.message.tool_calls.is_empty() {
for tc in &response.message.tool_calls {
eprintln!("[Tool Call] {}({})", tc.tool_name, tc.input);
}
}
if let Some(content) = &response.message.text {
println!("{}", content);
}
}
Ok(())
}
async fn run_models(args: ModelsArgs) -> Result<(), CliError> {
let config = resolve_config(ResolveInput {
provider: args.provider,
protocol: args.protocol,
api_key: args.api_key,
api_key_env: args.api_key_env,
base_url: args.base_url,
})?;
if !config.supports_model_catalog {
let provider = config.provider.as_deref().unwrap_or("this provider");
return Err(CliError::Config(format!(
"{} does not support model catalog API",
provider
)));
}
let catalog = create_model_catalog(&config)?;
let models = catalog.list_models().await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&models).unwrap());
} else {
println!("Available models:\n");
for model in models {
let display_name = model.display_name.as_deref().unwrap_or(&model.id);
println!(" {} - {}", model.id, display_name);
}
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
struct TestResult {
provider: String,
env_var: String,
status: String,
error: Option<String>,
}
async fn run_test(provider: Option<String>, json: bool) -> Result<(), CliError> {
let providers = provider.map(|p| vec![p]).unwrap_or_else(|| {
provider_registry::supported_providers()
.iter()
.filter(|p| !p.contains("compatible") && !p.contains("anthropic"))
.map(|s| s.to_string())
.collect()
});
let mut results: Vec<TestResult> = Vec::new();
for provider_name in providers {
let profile = match provider_registry::resolve_provider_profile(&provider_name) {
Some(p) => p,
None => {
results.push(TestResult {
provider: provider_name.clone(),
env_var: "-".to_string(),
status: "unknown".to_string(),
error: Some("Provider not found".to_string()),
});
continue;
}
};
let env_var = profile.default_api_key_env.unwrap_or("-");
if !profile.api_key_required {
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: "available".to_string(),
error: None,
});
continue;
}
let config_result = resolve_config(ResolveInput {
provider: Some(provider_name.clone()),
..Default::default()
});
match config_result {
Ok(config) => {
if config.supports_model_catalog {
match create_model_catalog(&config) {
Ok(catalog) => match catalog.list_models().await {
Ok(_) => {
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: "ok".to_string(),
error: None,
});
}
Err(e) => {
let error_msg = e.to_string();
let status = if error_msg.contains("401")
|| error_msg.contains("Unauthorized")
{
"invalid_key"
} else {
"error"
};
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: status.to_string(),
error: if status == "error" {
Some(error_msg)
} else {
None
},
});
}
},
Err(e) => {
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: "config_error".to_string(),
error: Some(e.to_string()),
});
}
}
} else {
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: "ok".to_string(),
error: None,
});
}
}
Err(e) => {
let error_msg = e.to_string();
let status = if error_msg.contains("missing API key")
|| error_msg.contains("environment variable not found")
{
"no_key"
} else {
"config_error"
};
results.push(TestResult {
provider: provider_name.clone(),
env_var: env_var.to_string(),
status: status.to_string(),
error: if status == "config_error" {
Some(error_msg)
} else {
None
},
});
}
}
}
if json {
println!("{}", serde_json::to_string_pretty(&results).unwrap());
} else {
println!("API Key Test Results:\n");
for result in &results {
let status_icon = match result.status.as_str() {
"ok" | "available" => "✓",
"no_key" => "○",
"invalid_key" => "✗",
_ => "?",
};
let status_text = match result.status.as_str() {
"ok" => "OK",
"available" => "Available (no key required)",
"no_key" => "No API key configured",
"invalid_key" => "Invalid API key",
other => other,
};
println!(
" {} {} ({}) - {}",
status_icon, result.provider, result.env_var, status_text
);
}
}
Ok(())
}