use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::cli::{ModelsLoraArgs, ModelsLoraCommand, ModelsLoraInspectArgs};
use crate::dispatch;
use crate::env_guard::ScopedEnvVar;
const LORA_INSPECT_PAYLOAD_ENV: &str = "HARN_MODELS_LORA_INSPECT_PAYLOAD_JSON";
const LORA_INSPECT_PAYLOAD_PRETTY_ENV: &str = "HARN_MODELS_LORA_INSPECT_PAYLOAD_PRETTY";
static DISPATCH_LORA_INSPECT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub(crate) async fn run(args: ModelsLoraArgs) {
let exit_code = match args.command {
ModelsLoraCommand::Inspect(args) => inspect(&args).await,
};
if exit_code != 0 {
std::process::exit(exit_code);
}
}
async fn inspect(args: &ModelsLoraInspectArgs) -> i32 {
let report = match inspect_report(args) {
Ok(report) => report,
Err(error) => {
eprintln!("error: {error}");
return 1;
}
};
let payload_json = match serde_json::to_string(&report) {
Ok(json) => json,
Err(error) => {
eprintln!("error: failed to serialise LoRA inspect payload: {error}");
return 1;
}
};
let pretty_json = match serde_json::to_string_pretty(&report) {
Ok(json) => json,
Err(error) => {
eprintln!("error: failed to render LoRA inspect JSON: {error}");
return 1;
}
};
let _guard = DISPATCH_LORA_INSPECT_LOCK.lock().await;
let _payload = ScopedEnvVar::set(LORA_INSPECT_PAYLOAD_ENV, &payload_json);
let _pretty = ScopedEnvVar::set(LORA_INSPECT_PAYLOAD_PRETTY_ENV, &pretty_json);
let outcome = dispatch::run_embedded_script("models/lora_inspect", Vec::new(), args.json).await;
if !outcome.stderr.is_empty() {
let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
}
if !outcome.stdout.is_empty() {
let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
}
outcome.exit_code
}
fn inspect_report(args: &ModelsLoraInspectArgs) -> Result<LoraInspectReport, String> {
let resolved = harn_vm::llm_config::resolve_model_info(&args.base_model);
let provider = args
.provider
.as_deref()
.map(str::trim)
.filter(|provider| !provider.is_empty())
.map(str::to_string)
.unwrap_or_else(|| resolved.provider.clone());
let catalog = harn_vm::llm_config::model_catalog_entry(&resolved.id);
let capabilities = harn_vm::llm::capabilities::lookup(&provider, &resolved.id);
let tool_format = harn_vm::llm_config::default_tool_format(&resolved.id, &provider);
let adapter = inspect_adapter(&args.adapter, args.name.as_deref())?;
let local_runtime =
harn_vm::llm_config::provider_config(&provider).and_then(|provider| provider.local_runtime);
let provider_supports_lora_launch = local_runtime
.as_ref()
.and_then(|runtime| runtime.lora_modules_arg.as_ref())
.is_some();
let provider_supports_lora_max_rank = local_runtime
.as_ref()
.and_then(|runtime| runtime.max_lora_rank_arg.as_ref())
.is_some();
let base_model_match =
base_model_match(adapter.base_model_name_or_path.as_deref(), &resolved.id);
let mut warnings = Vec::new();
if adapter.exists && !adapter.config_found {
warnings.push("local adapter exists but adapter_config.json was not found".to_string());
}
if adapter.exists && adapter.weights_found.is_empty() {
warnings.push("local adapter has no adapter_model.* weight file".to_string());
}
if adapter
.peft_type
.as_deref()
.is_some_and(|peft| peft != "LORA")
{
warnings.push("adapter_config.json peft_type is not LORA".to_string());
}
if matches!(base_model_match, BaseModelMatch::Mismatch) {
warnings.push(format!(
"adapter base_model_name_or_path does not match resolved base model {}",
resolved.id
));
}
if !adapter.exists {
warnings.push(
"adapter path does not exist locally; treating it as a remote/runtime-resolved id"
.to_string(),
);
}
if !provider_supports_lora_launch {
warnings.push(format!(
"provider {provider} does not declare local-runtime LoRA launch flags"
));
}
if adapter.rank.is_some() && provider_supports_lora_launch && !provider_supports_lora_max_rank {
warnings.push(format!(
"adapter rank is known but provider {provider} does not declare a max LoRA rank flag"
));
}
let ok = warnings.iter().all(|warning| {
!warning.starts_with("local adapter exists")
&& !warning.starts_with("adapter_config.json peft_type")
&& !warning.starts_with("adapter base_model_name_or_path")
});
let request_model = adapter.name.clone();
let model_source = adapter
.base_model_name_or_path
.clone()
.unwrap_or_else(|| resolved.id.clone());
let lora_spec = format!("{}={}", adapter.name, adapter.input);
let launch_provider = provider.clone();
let max_lora_rank = adapter
.rank
.filter(|_| provider_supports_lora_launch && provider_supports_lora_max_rank);
let mut harn_local_launch = vec![
"harn".to_string(),
"local".to_string(),
"launch".to_string(),
args.base_model.clone(),
"--provider".to_string(),
launch_provider,
"--model-source".to_string(),
model_source,
"--lora-adapter".to_string(),
lora_spec,
];
if let Some(rank) = max_lora_rank {
harn_local_launch.extend(["--max-lora-rank".to_string(), rank.to_string()]);
}
Ok(LoraInspectReport {
ok,
base: BaseModelReport {
selector: args.base_model.clone(),
id: resolved.id.clone(),
provider,
resolved_alias: resolved.alias,
tool_format,
tier: resolved.tier,
family: resolved.family,
lineage: resolved.lineage,
catalog_name: catalog.as_ref().map(|model| model.name.clone()),
context_window: catalog.as_ref().map(|model| model.context_window),
},
adapter,
compatibility: CompatibilityReport {
base_model_match,
provider_supports_lora_launch,
provider_supports_lora_max_rank,
},
tool_calling: ToolCallingReport {
native_tools: capabilities.native_tools,
preferred_tool_format: capabilities.preferred_tool_format,
text_tool_wire_format_supported: capabilities.text_tool_wire_format_supported,
structured_output_mode: capabilities.structured_output_mode,
recommended_endpoint: capabilities.recommended_endpoint,
},
launch: LaunchHints {
request_model,
max_lora_rank,
harn_local_launch,
},
warnings,
})
}
fn inspect_adapter(input: &str, explicit_name: Option<&str>) -> Result<AdapterReport, String> {
let expanded = expand_home(input);
let path = PathBuf::from(&expanded);
let exists = path.exists();
let adapter_dir = if path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "adapter_config.json")
{
path.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
} else {
path
};
let config_path = adapter_dir.join("adapter_config.json");
let config_found = config_path.is_file();
let config = if config_found {
let raw = std::fs::read_to_string(&config_path)
.map_err(|error| format!("failed to read {}: {error}", config_path.display()))?;
Some(
serde_json::from_str::<serde_json::Value>(&raw)
.map_err(|error| format!("failed to parse {}: {error}", config_path.display()))?,
)
} else {
None
};
let weights_found = adapter_weights(&adapter_dir);
Ok(AdapterReport {
input: input.to_string(),
name: explicit_name
.map(str::to_string)
.unwrap_or_else(|| adapter_name_from_input(input)),
local_path: exists.then(|| adapter_dir.display().to_string()),
exists,
config_found,
config_path: config_found.then(|| config_path.display().to_string()),
weights_found,
peft_type: config_string(&config, "peft_type"),
task_type: config_string(&config, "task_type"),
base_model_name_or_path: config_string(&config, "base_model_name_or_path"),
rank: config_u64(&config, "r"),
lora_alpha: config_f64(&config, "lora_alpha"),
target_modules: config_string_list(&config, "target_modules"),
})
}
fn adapter_weights(dir: &Path) -> Vec<String> {
["adapter_model.safetensors", "adapter_model.bin"]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.is_file().then(|| path.display().to_string())
})
.collect()
}
fn config_string(config: &Option<serde_json::Value>, key: &str) -> Option<String> {
config.as_ref()?.get(key)?.as_str().map(str::to_string)
}
fn config_u64(config: &Option<serde_json::Value>, key: &str) -> Option<u64> {
config.as_ref()?.get(key)?.as_u64()
}
fn config_f64(config: &Option<serde_json::Value>, key: &str) -> Option<f64> {
let value = config.as_ref()?.get(key)?;
value.as_f64().or_else(|| value.as_u64().map(|n| n as f64))
}
fn config_string_list(config: &Option<serde_json::Value>, key: &str) -> Vec<String> {
let Some(value) = config.as_ref().and_then(|value| value.get(key)) else {
return Vec::new();
};
if let Some(text) = value.as_str() {
return vec![text.to_string()];
}
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn base_model_match(declared: Option<&str>, resolved_id: &str) -> BaseModelMatch {
let Some(declared) = declared.map(str::trim).filter(|value| !value.is_empty()) else {
return BaseModelMatch::Unknown;
};
let declared = normalize_model_name(declared);
let resolved = normalize_model_name(resolved_id);
if declared == resolved {
return BaseModelMatch::Exact;
}
let declared_tail = declared.rsplit('/').next().unwrap_or(&declared);
let resolved_tail = resolved.rsplit('/').next().unwrap_or(&resolved);
if declared_tail == resolved_tail {
BaseModelMatch::Suffix
} else {
BaseModelMatch::Mismatch
}
}
fn normalize_model_name(value: &str) -> String {
value
.trim()
.trim_start_matches("models/")
.to_ascii_lowercase()
}
fn adapter_name_from_input(input: &str) -> String {
input
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or("lora-adapter")
.to_string()
}
fn expand_home(value: &str) -> String {
if let Some(rest) = value.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest).display().to_string();
}
}
value.to_string()
}
#[derive(Debug, Serialize)]
struct LoraInspectReport {
ok: bool,
base: BaseModelReport,
adapter: AdapterReport,
compatibility: CompatibilityReport,
tool_calling: ToolCallingReport,
launch: LaunchHints,
warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
struct BaseModelReport {
selector: String,
id: String,
provider: String,
resolved_alias: Option<String>,
tool_format: String,
tier: String,
family: String,
lineage: String,
catalog_name: Option<String>,
context_window: Option<u64>,
}
#[derive(Debug, Serialize)]
struct AdapterReport {
input: String,
name: String,
local_path: Option<String>,
exists: bool,
config_found: bool,
config_path: Option<String>,
weights_found: Vec<String>,
peft_type: Option<String>,
task_type: Option<String>,
base_model_name_or_path: Option<String>,
rank: Option<u64>,
lora_alpha: Option<f64>,
target_modules: Vec<String>,
}
#[derive(Debug, Serialize)]
struct CompatibilityReport {
base_model_match: BaseModelMatch,
provider_supports_lora_launch: bool,
provider_supports_lora_max_rank: bool,
}
#[derive(Debug, Serialize)]
struct ToolCallingReport {
native_tools: bool,
preferred_tool_format: Option<String>,
text_tool_wire_format_supported: bool,
structured_output_mode: String,
recommended_endpoint: Option<String>,
}
#[derive(Debug, Serialize)]
struct LaunchHints {
request_model: String,
max_lora_rank: Option<u64>,
harn_local_launch: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum BaseModelMatch {
Exact,
Suffix,
Mismatch,
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspects_local_peft_lora_config() {
let tmp = tempfile::tempdir().expect("tempdir");
let adapter_dir = tmp.path().join("burin-tools");
std::fs::create_dir(&adapter_dir).expect("adapter dir");
std::fs::write(adapter_dir.join("adapter_model.safetensors"), b"stub")
.expect("adapter weights");
std::fs::write(
adapter_dir.join("adapter_config.json"),
r#"{
"peft_type": "LORA",
"base_model_name_or_path": "google/gemma-4-e4b-it",
"task_type": "CAUSAL_LM",
"r": 16,
"lora_alpha": 32,
"target_modules": ["q_proj", "v_proj"]
}"#,
)
.expect("adapter config");
let args = ModelsLoraInspectArgs {
base_model: "local-gemma4-e4b".to_string(),
adapter: adapter_dir.display().to_string(),
name: Some("burin-tools".to_string()),
provider: Some("vllm".to_string()),
json: true,
};
let report = inspect_report(&args).expect("report");
assert!(report.ok, "{:?}", report.warnings);
assert_eq!(report.adapter.peft_type.as_deref(), Some("LORA"));
assert_eq!(report.adapter.rank, Some(16));
assert_eq!(report.base.tool_format, "json");
assert!(!report.tool_calling.native_tools);
assert_eq!(
report.compatibility.base_model_match,
BaseModelMatch::Suffix
);
assert!(report.compatibility.provider_supports_lora_launch);
assert!(report.compatibility.provider_supports_lora_max_rank);
assert_eq!(report.launch.request_model, "burin-tools");
assert_eq!(report.launch.max_lora_rank, Some(16));
assert!(report
.launch
.harn_local_launch
.iter()
.any(|arg| arg == "--lora-adapter"));
assert!(report
.launch
.harn_local_launch
.windows(2)
.any(|pair| pair == ["--max-lora-rank", "16"]));
}
#[test]
fn mismatched_base_model_marks_report_failed() {
let tmp = tempfile::tempdir().expect("tempdir");
let adapter_dir = tmp.path().join("other");
std::fs::create_dir(&adapter_dir).expect("adapter dir");
std::fs::write(adapter_dir.join("adapter_model.safetensors"), b"stub")
.expect("adapter weights");
std::fs::write(
adapter_dir.join("adapter_config.json"),
r#"{"peft_type":"LORA","base_model_name_or_path":"other/model"}"#,
)
.expect("adapter config");
let args = ModelsLoraInspectArgs {
base_model: "local-gemma4-e4b".to_string(),
adapter: adapter_dir.display().to_string(),
name: None,
provider: Some("vllm".to_string()),
json: true,
};
let report = inspect_report(&args).expect("report");
assert!(!report.ok);
assert_eq!(
report.compatibility.base_model_match,
BaseModelMatch::Mismatch
);
}
}