use std::fs; use std::path::Path; use serde::Deserialize; pub(crate) const DEFAULT_CONFIG_PATH: &str = ".config/config.toml"; #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct VastAiConfig { pub api_key: Option, pub image: Option, pub relay_url: Option, pub bootstrap_command: Option, pub disk_gb: Option, pub ssh_user: Option, pub confirm_lease: Option, pub gpu_name: Option, pub min_gpu_ram_mb: Option, pub min_down_mbps: Option, pub min_up_mbps: Option, pub max_dph_total: Option, pub min_reliability: Option, pub require_verified: Option, pub blacklist_hosts: Vec, pub poll_interval_secs: Option, pub onstart: Option, pub ssh_identity: Option, } #[derive(Clone, Debug, PartialEq)] pub(crate) struct ResolvedVastAiConfig { pub api_key: String, pub relay_url: String, pub image: String, pub bootstrap_command: String, pub disk_gb: Option, pub gpu_name: Option, pub min_gpu_ram_mb: Option, pub min_down_mbps: Option, pub min_up_mbps: Option, pub max_dph_total: Option, pub min_reliability: Option, pub require_verified: Option, pub blacklist_hosts: Vec, pub onstart: Option, pub ssh_identity: Option, } impl ResolvedVastAiConfig { pub(crate) fn validate(self) -> Result { require_non_empty("VAST_API_KEY", &self.api_key)?; require_non_empty("relay.url", &self.relay_url)?; require_non_empty("vastai.image", &self.image)?; require_non_empty("vastai.bootstrap_command", &self.bootstrap_command)?; if let Some(identity) = &self.ssh_identity { require_non_empty("vastai.ssh_identity", identity)?; } if !looks_remote_image(&self.image) { return Err(format!( "vastai.image {:?} must include a registry namespace", self.image )); } Ok(self) } } fn require_non_empty(label: &str, value: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("missing required {label}")) } else { Ok(()) } } pub(crate) fn looks_remote_image(image: &str) -> bool { let repository = image.split('@').next().unwrap_or(image); let last_slash = repository.rfind('/'); let tag_separator = repository .rfind(':') .filter(|separator| last_slash.is_some_and(|slash| *separator > slash)); let repository = tag_separator.map_or(repository, |separator| &repository[..separator]); let Some((host, _)) = repository.split_once('/') else { return false; }; host == "localhost" || host.contains('.') || host.contains(':') } /// Shared overlay for legacy and multi-binary configuration parsing. This accepts /// fields outside the fixed `mvp-chat` public config surface; `mvp-chat` uses a /// bin-local strict config loader instead. #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct TomlConfigOverlay { pub runtime: RuntimeConfigOverlay, pub provider: ProviderConfigOverlay, pub image: ImageConfig, pub relay: RelayConfig, pub prompt: PromptConfig, pub model: ModelConfig, pub docker: DockerConfigOverlay, pub observability: ObservabilityConfigOverlay, pub vastai: VastAiConfig, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct RuntimeConfigOverlay { pub profile: Option, pub run_id: Option, pub node_id: Option, pub stage_index: Option, pub layer_end_exclusive: Option, pub pipeline_stages: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct ProviderConfigOverlay { pub kind: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct ImageConfig { pub node: Option, pub tag: Option, pub build: Option, pub push: Option, pub force_refresh: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct RelayConfig { pub mode: Option, pub url: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct PromptConfig { pub rpc_addr: Option, pub max_tokens: Option, pub dashboard: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct ModelConfig { pub id: Option, pub gguf_local_path: Option, pub gguf_repo: Option, pub gguf_file: Option, pub gguf_revision: Option, pub tokenizer_local_path: Option, pub max_context: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct DockerConfigOverlay { pub gpus: Option, pub cached_model_host_path: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(default)] pub(crate) struct ObservabilityConfigOverlay { pub dump_logs: Option, pub dump_log_path: Option, pub datastream_frame_log: Option, } impl TomlConfigOverlay { pub(crate) fn load_optional(path: &Path) -> Result, String> { if path.is_file() { Self::load_required(path).map(Some) } else { Ok(None) } } pub(crate) fn load_required(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|e| format!("read config {}: {e}", path.display()))?; Self::from_str(&text).map_err(|e| format!("parse config {}: {e}", path.display())) } pub(crate) fn from_str(text: &str) -> Result { toml::from_str(text) } }