use std::{path::Path, time::Duration};
use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::{
error::{LmcppError, LmcppResult},
server::{
toolchain::recipe::LmcppRecipe,
types::file::{ValidDir, ValidFile},
},
};
const DEFAULT_PROJECT_NAME: &str = "llama_cpp_toolchain";
const DEFAULT_FAIL_LIMIT: u8 = 3;
const LLAMA_CPP_DEFAULT_TAG: &str = "b6097";
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub const LMCPP_SERVER_EXECUTABLE: &str = "llama-server";
#[cfg(target_os = "windows")]
pub const LMCPP_SERVER_EXECUTABLE: &str = "llama-server.exe";
#[derive(Serialize, Deserialize, Debug, Clone, Builder)]
#[builder(derive(Debug, Clone), finish_fn(vis = "", name = build_internal))]
pub struct LmcppToolChain {
#[builder(field)]
build_args: ArgSet,
#[builder(into)]
pub custom_bin_path: Option<std::path::PathBuf>,
#[builder(default = DEFAULT_PROJECT_NAME.to_string(), into)]
pub project: String,
#[builder(with = |dir: impl TryInto<ValidDir, Error = LmcppError>| -> LmcppResult<_> {
dir.try_into()
})]
pub override_root: Option<ValidDir>,
#[builder(default = DEFAULT_FAIL_LIMIT)]
pub fail_limit: u8,
#[builder(default = LLAMA_CPP_DEFAULT_TAG.to_string(), into)]
pub repo_tag: String,
#[builder(default, name = compute_backend)]
pub compute_cfg: ComputeBackendConfig,
#[builder(default, setters(vis = "", name = mode_internal))]
pub mode: LmcppBuildInstallMode,
#[builder(default = false)]
pub curl_enabled: bool,
#[builder(with = || true, default = false)]
pub rpc_enabled: bool,
#[builder(with = || true, default = false)]
pub llguidance_enabled: bool,
}
impl Default for LmcppToolChain {
fn default() -> Self {
LmcppToolChain::builder()
.build()
.expect("Default toolchain should always be available")
}
}
impl LmcppToolChain {
pub const RPC_OFF: &str = "-DGGML_RPC=OFF";
pub const CURL_OFF: &str = "-DLLAMA_CURL=OFF";
pub const LLGUIDANCE_ON: &str = "-DLLAMA_LLGUIDANCE=ON";
pub const METAL_OFF: &str = "-DGGML_METAL=OFF";
pub const METAL_ON: &str = "-DGGML_METAL=ON";
pub const CUDA_ARG: &str = "-DGGML_CUDA=ON";
pub fn run(&self) -> LmcppResult<LmcppToolchainOutcome> {
if let Some(custom_bin_path) = &self.custom_bin_path {
return self.validate_custom_bin_path(custom_bin_path);
}
let mut recipe = LmcppRecipe::new(
&self.project,
&self.override_root,
self.fail_limit,
&self.repo_tag,
&self.compute_cfg,
&self.mode,
&self.build_args,
)?;
let res = recipe.run()?;
Ok(res)
}
pub fn validate(&self) -> LmcppResult<LmcppToolchainOutcome> {
if let Some(custom_bin_path) = &self.custom_bin_path {
return self.validate_custom_bin_path(custom_bin_path);
}
let mut recipe = LmcppRecipe::new(
&self.project,
&self.override_root,
self.fail_limit,
&self.repo_tag,
&self.compute_cfg,
&self.mode,
&self.build_args,
)?;
let res = recipe.validate()?;
Ok(res)
}
pub fn remove(self) -> LmcppResult<()> {
let mut recipe = LmcppRecipe::new(
&self.project,
&self.override_root,
self.fail_limit,
&self.repo_tag,
&self.compute_cfg,
&self.mode,
&self.build_args,
)?;
recipe.remove()
}
fn validate_custom_bin_path(
&self,
custom_bin_path: &std::path::Path,
) -> LmcppResult<LmcppToolchainOutcome> {
let t0 = std::time::Instant::now();
let bin_path = ValidFile::new(custom_bin_path)?;
let executable_name = bin_path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| LmcppError::InvalidConfig {
field: "custom_bin_path",
reason: "No executable name in custom binary path".into(),
})?
.to_string();
let compute_backend: ComputeBackend = self.compute_cfg.to_backend(&self.mode)?;
Ok(LmcppToolchainOutcome {
duration: t0.elapsed(),
bin_path: Some(bin_path),
status: LmcppBuildInstallStatus::CustomBinPath,
repo_tag: "custom_bin_path".to_string(),
compute_backend, executable_name,
error: None, })
}
}
use lmcpp_tool_chain_builder::{IsUnset, SetMode, State};
impl<S: State> LmcppToolChainBuilder<S> {
pub fn build_arg(mut self, arg: impl Into<String>) -> Self {
self.build_args.insert(arg.into());
self
}
pub fn build_args<I, T>(self, args: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
args.into_iter().fold(self, |b, a| b.build_arg(a))
}
pub fn build_only(self) -> LmcppToolChainBuilder<SetMode<S>>
where
S::Mode: IsUnset,
{
self.mode_internal(LmcppBuildInstallMode::BuildOnly)
}
pub fn install_only(self) -> LmcppToolChainBuilder<SetMode<S>>
where
S::Mode: IsUnset,
{
self.mode_internal(LmcppBuildInstallMode::InstallOnly)
}
pub fn build_or_install(self) -> LmcppToolChainBuilder<SetMode<S>>
where
S::Mode: IsUnset,
{
self.mode_internal(LmcppBuildInstallMode::BuildOrInstall)
}
pub fn build_install_mode(
self,
mode: LmcppBuildInstallMode,
) -> LmcppToolChainBuilder<SetMode<S>>
where
S::Mode: IsUnset,
{
self.mode_internal(mode)
}
}
impl<S: lmcpp_tool_chain_builder::IsComplete> LmcppToolChainBuilder<S> {
pub fn build(self) -> LmcppResult<LmcppToolChain> {
let mut chain = self.build_internal();
if chain.project.is_empty() {
return Err(LmcppError::InvalidConfig {
field: "project",
reason: "cannot be empty".into(),
});
}
if chain.repo_tag.is_empty() {
return Err(LmcppError::InvalidConfig {
field: "repo_tag",
reason: "cannot be empty".into(),
});
}
if chain.fail_limit == 0 {
return Err(LmcppError::InvalidConfig {
field: "fail_limit",
reason: "must be greater than zero".into(),
});
}
if !chain.curl_enabled {
chain.build_args.insert(LmcppToolChain::CURL_OFF.into());
}
if !chain.rpc_enabled {
chain.build_args.insert(LmcppToolChain::RPC_OFF.into());
}
if chain.llguidance_enabled {
chain
.build_args
.insert(LmcppToolChain::LLGUIDANCE_ON.into());
}
if chain.build_args.iter().any(|s| s.is_empty()) {
return Err(LmcppError::InvalidConfig {
field: "build args",
reason: "individual arguments cannot be empty".into(),
});
}
Ok(chain)
}
}
impl std::fmt::Display for LmcppToolChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write;
writeln!(f, "LmcppToolChain:")?;
let mut indented = indenter::indented(f).with_str(" ");
writeln!(indented, "Project: {}", self.project)?;
writeln!(indented, "Repo tag: {}", self.repo_tag)?;
writeln!(indented, "Compute backend: {:?}", self.compute_cfg)?;
writeln!(indented, "Mode: {:?}", self.mode)?;
writeln!(indented, "Build args: {:?}", self.build_args)?;
Ok(())
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
#[repr(transparent)]
pub struct ArgSet(std::collections::BTreeSet<String>);
impl std::ops::Deref for ArgSet {
type Target = std::collections::BTreeSet<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for ArgSet {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug, clap::ValueEnum, Clone, Copy)]
pub enum LmcppBuildInstallMode {
BuildOnly,
InstallOnly,
BuildOrInstall,
}
impl Default for LmcppBuildInstallMode {
fn default() -> Self {
LmcppBuildInstallMode::BuildOrInstall
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum LmcppBuildInstallStatus {
Installed,
Built,
NotBuiltOrInstalled,
CustomBinPath,
}
impl Default for LmcppBuildInstallStatus {
fn default() -> Self {
LmcppBuildInstallStatus::NotBuiltOrInstalled
}
}
#[derive(Serialize, Debug)]
pub struct LmcppToolchainOutcome {
pub duration: Duration,
pub repo_tag: String,
pub status: LmcppBuildInstallStatus,
pub compute_backend: ComputeBackend,
pub bin_path: Option<ValidFile>,
pub executable_name: String,
pub error: Option<LmcppError>,
}
impl LmcppToolchainOutcome {
pub fn bin_path(&self) -> Option<&Path> {
self.bin_path.as_ref().map(|f| f.as_ref())
}
pub fn bin_dir(&self) -> Option<&Path> {
self.bin_path()?.parent()
}
}
impl std::fmt::Display for LmcppToolchainOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write;
writeln!(f, "LmcppToolchainOutcome:")?;
let mut indented = indenter::indented(f).with_str(" ");
writeln!(indented, "Duration: {:?}", self.duration)?;
writeln!(indented, "Repo tag: {}", self.repo_tag)?;
writeln!(indented, "Status: {:?}", self.status)?;
writeln!(indented, "Compute backend: {}", self.compute_backend)?;
if let Some(bin_path) = &self.bin_path {
writeln!(indented, "Binary path: {}", bin_path.display())?;
} else {
writeln!(indented, "Binary path: None")?;
}
if let Some(error) = &self.error {
writeln!(indented, "Error: {}", error)?;
} else {
writeln!(indented, "Error: None")?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
pub enum ComputeBackendConfig {
Default,
Cpu,
Cuda,
CudaIfAvailable,
Metal,
MetalIfAvailable,
}
impl ComputeBackendConfig {
#[cfg(target_os = "macos")]
pub fn validate_cuda(_: &LmcppBuildInstallMode) -> LmcppResult<ComputeBackend> {
return Err(LmcppError::BackendUnavailable {
what: "CUDA",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
reason: "Not Linux or Windows".into(),
});
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub fn validate_cuda(mode: &LmcppBuildInstallMode) -> LmcppResult<ComputeBackend> {
{
use nvml_wrapper::Nvml;
let nvml = Nvml::init().map_err(|e| LmcppError::BackendUnavailable {
what: "CUDA",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
reason: format!("NVML initialisation failed: {e}"),
})?;
if nvml.device_count().unwrap_or(0) == 0 {
return Err(LmcppError::BackendUnavailable {
what: "CUDA",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
reason: "no CUDA-capable GPU detected".into(),
});
}
match mode {
LmcppBuildInstallMode::BuildOnly => {
let nvcc_ok = std::process::Command::new("nvcc")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !nvcc_ok {
return Err(LmcppError::BackendUnavailable {
what: "CUDA",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
reason: "CUDA toolkit required to build with CUDA support. Install it, or switch to LmcppBuildInstallMode::InstallOnly.".into(),
});
}
}
_ => {}
}
Ok(ComputeBackend::Cuda)
}
}
pub fn validate_metal() -> LmcppResult<ComputeBackend> {
if cfg!(not(target_os = "macos")) {
return Err(LmcppError::BackendUnavailable {
what: "Metal",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
reason: "Not macOS".into(),
});
}
Ok(ComputeBackend::Metal)
}
pub fn to_backend(self, mode: &LmcppBuildInstallMode) -> LmcppResult<ComputeBackend> {
match self {
ComputeBackendConfig::Default => Self::default_backend(mode),
ComputeBackendConfig::Cpu => Ok(ComputeBackend::Cpu),
ComputeBackendConfig::Cuda => Self::validate_cuda(mode),
ComputeBackendConfig::CudaIfAvailable => Self::cuda_if_available(mode),
ComputeBackendConfig::Metal => Self::validate_metal(),
ComputeBackendConfig::MetalIfAvailable => Self::metal_if_available(),
}
}
fn default_backend(mode: &LmcppBuildInstallMode) -> LmcppResult<ComputeBackend> {
if cfg!(target_os = "macos") {
Self::metal_if_available()
} else if cfg!(any(target_os = "linux", target_os = "windows")) {
Self::validate_cuda(mode)
.or_else(|_| Ok(ComputeBackend::Cpu))
} else {
Ok(ComputeBackend::Cpu)
}
}
fn cuda_if_available(mode: &LmcppBuildInstallMode) -> LmcppResult<ComputeBackend> {
match Self::validate_cuda(mode) {
Ok(backend) => Ok(backend),
Err(_) => Ok(ComputeBackend::Cpu), }
}
fn metal_if_available() -> LmcppResult<ComputeBackend> {
match Self::validate_metal() {
Ok(_) => Ok(ComputeBackend::Metal),
Err(_) => Ok(ComputeBackend::Cpu), }
}
}
impl Default for ComputeBackendConfig {
fn default() -> Self {
ComputeBackendConfig::Default
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ComputeBackend {
Cpu,
Cuda,
Metal,
}
impl std::fmt::Display for ComputeBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComputeBackend::Cpu => write!(f, "CPU"),
ComputeBackend::Cuda => write!(f, "CUDA"),
ComputeBackend::Metal => write!(f, "Metal"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_args_are_sorted_and_deduped() {
let chain = LmcppToolChain::builder()
.build_arg("-DGGML_RPC=OFF") .build_arg("-DLLAMA_CURL=OFF")
.build_arg("-DGGML_RPC=OFF") .build_only()
.build()
.expect("builder must succeed");
let actual: Vec<_> = chain.build_args.iter().cloned().collect();
let expected = vec!["-DGGML_RPC=OFF".to_string(), "-DLLAMA_CURL=OFF".to_string()];
assert_eq!(
actual, expected,
"builder should sort lexicographically and drop duplicates"
);
}
#[test]
fn builder_invalid_inputs_error_out() {
let cases: Vec<(&str, LmcppResult<LmcppToolChain>)> = vec![
(
"empty project",
LmcppToolChain::builder().project("").build_only().build(),
),
(
"empty repo_tag",
LmcppToolChain::builder().repo_tag("").build_only().build(),
),
(
"zero fail_limit",
LmcppToolChain::builder().fail_limit(0).build_only().build(),
),
(
"empty build-arg",
LmcppToolChain::builder().build_arg("").build_only().build(),
),
];
for (name, res) in cases {
assert!(res.is_err(), "builder must reject invalid input: {}", name);
}
}
#[test]
fn flag_injection_scenarios() {
struct Scenario {
name: &'static str,
chain: LmcppToolChain,
expect_curl_off: bool,
expect_rpc_off: bool,
expect_llg_on: bool,
}
let scenarios = vec![
Scenario {
name: "defaults",
chain: LmcppToolChain::builder()
.build_only()
.build()
.expect("defaults"),
expect_curl_off: true,
expect_rpc_off: true,
expect_llg_on: false,
},
Scenario {
name: "llguidance ON",
chain: LmcppToolChain::builder()
.llguidance_enabled()
.build_only()
.build()
.expect("llguidance ON"),
expect_curl_off: true,
expect_rpc_off: true,
expect_llg_on: true,
},
Scenario {
name: "curl enabled",
chain: LmcppToolChain::builder()
.curl_enabled(true)
.build_only()
.build()
.expect("curl enabled"),
expect_curl_off: false,
expect_rpc_off: true,
expect_llg_on: false,
},
Scenario {
name: "rpc enabled",
chain: LmcppToolChain::builder()
.rpc_enabled()
.build_only()
.build()
.expect("rpc enabled"),
expect_curl_off: true,
expect_rpc_off: false,
expect_llg_on: false,
},
Scenario {
name: "all toggled",
chain: LmcppToolChain::builder()
.curl_enabled(true)
.rpc_enabled()
.llguidance_enabled()
.build_only()
.build()
.expect("all toggled"),
expect_curl_off: false,
expect_rpc_off: false,
expect_llg_on: true,
},
];
for s in scenarios {
let flags = &s.chain.build_args;
assert_eq!(
flags.contains(LmcppToolChain::CURL_OFF),
s.expect_curl_off,
"{}: CURL_OFF presence mismatch",
s.name
);
assert_eq!(
flags.contains(LmcppToolChain::RPC_OFF),
s.expect_rpc_off,
"{}: RPC_OFF presence mismatch",
s.name
);
assert_eq!(
flags.contains(LmcppToolChain::LLGUIDANCE_ON),
s.expect_llg_on,
"{}: LLGUIDANCE_ON presence mismatch",
s.name
);
}
}
#[test]
fn build_install_mode_helpers() {
assert_eq!(
LmcppToolChain::builder().build_only().build().unwrap().mode,
LmcppBuildInstallMode::BuildOnly
);
assert_eq!(
LmcppToolChain::builder()
.install_only()
.build()
.unwrap()
.mode,
LmcppBuildInstallMode::InstallOnly
);
assert_eq!(
LmcppToolChain::builder()
.build_or_install()
.build()
.unwrap()
.mode,
LmcppBuildInstallMode::BuildOrInstall
);
assert_eq!(
LmcppBuildInstallMode::default(),
LmcppBuildInstallMode::BuildOrInstall
);
}
#[test]
fn compute_backend_display_strings() {
assert_eq!(ComputeBackend::Cpu.to_string(), "CPU");
assert_eq!(ComputeBackend::Cuda.to_string(), "CUDA");
assert_eq!(ComputeBackend::Metal.to_string(), "Metal");
}
#[test]
fn compute_backend_config_to_backend_cpu() {
let backend = ComputeBackendConfig::Cpu
.to_backend(&LmcppBuildInstallMode::default())
.expect("CPU backend must always be available");
assert_eq!(backend, ComputeBackend::Cpu);
}
}