use super::types::{
Adapter, BUILTIN_SSH_REFUSED, Effort, HAS_BUILTIN_SSH, ListOrAuto, Protocol, QUANTIZE_TYPES,
RoleModel, Runtime, Settings, SshClient, Target, Training, is_ollama_name,
};
const MAX_CONCURRENCY: usize = 1024;
const MIN_THINKING_BUDGET: u32 = 1024;
pub(crate) fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub(crate) fn check(settings: &Settings) -> Vec<String> {
let mut problems = Vec::new();
check_names(settings, &mut problems);
check_roles(settings, &mut problems);
check_role_params(settings, &mut problems);
check_topics(settings, &mut problems);
check_pipeline(settings, &mut problems);
check_training(settings, &mut problems);
check_export(settings, &mut problems);
check_compare(settings, &mut problems);
check_targets(settings, &mut problems);
check_runpod(settings, &mut problems);
check_hub(settings, &mut problems);
problems
}
pub fn deprecations(settings: &Settings) -> Vec<String> {
let mut found = Vec::new();
if settings
.training
.as_ref()
.is_some_and(|training| training.hub_model_id.is_some())
{
found.push(
"training.hub_model_id is deprecated, use [hub] repo (overbrainer migrate moves it)"
.to_string(),
);
}
found
}
pub fn hub_token_warning(settings: &Settings) -> Option<String> {
(settings.hub.after_training && settings.hf_token.is_none()).then(|| {
"[hub] after_training is on but OVERBRAINER_HF_TOKEN is not set: the push after \
training will fail"
.to_string()
})
}
pub fn legacy_hub_token_warning(settings: &Settings) -> Option<String> {
let legacy = settings
.training
.as_ref()
.is_some_and(|training| training.hub_model_id.is_some());
(legacy && settings.hf_token.is_none() && hub_token_warning(settings).is_none()).then(|| {
"training.hub_model_id is set but OVERBRAINER_HF_TOKEN is not: the push will fail"
.to_string()
})
}
fn check_names(settings: &Settings, problems: &mut Vec<String>) {
let names = settings
.providers
.keys()
.map(|name| ("providers", name))
.chain(settings.targets.keys().map(|name| ("targets", name)));
for (table, name) in names {
if !is_valid_name(name) {
problems.push(format!("{table}.{name}: name must match ^[a-z0-9_]+$"));
}
}
}
fn check_roles(settings: &Settings, problems: &mut Vec<String>) {
for (role, model) in settings.roles.all() {
if !settings.providers.contains_key(&model.provider) {
problems.push(format!(
"roles.{role}: unknown provider `{}`",
model.provider
));
}
}
if let Some(embedder) = &settings.roles.embedder
&& settings
.providers
.get(&embedder.provider)
.is_some_and(|provider| provider.protocol == Protocol::Anthropic)
{
problems.push(
"roles.embedder: the anthropic protocol has no embeddings, use an openai provider"
.to_string(),
);
}
}
fn check_role_params(settings: &Settings, problems: &mut Vec<String>) {
for (role, model) in settings.roles.all() {
if model.max_tokens == 0 {
problems.push(format!("roles.{role}.max_tokens: must be at least 1"));
}
if let Some(temperature) = model.temperature
&& !(0.0..=2.0).contains(&temperature)
{
problems.push(format!("roles.{role}.temperature: must be in [0, 2]"));
}
check_reasoning_effort(settings, role, model, problems);
}
check_thinking_temperature(settings, problems);
check_thinking_budget(settings, problems);
}
fn check_reasoning_effort(
settings: &Settings,
role: &str,
model: &RoleModel,
problems: &mut Vec<String>,
) {
match model.reasoning_effort {
Some(Effort::None) => {
if model.reasoning {
problems.push(format!(
"roles.{role}.reasoning_effort: \"none\" cannot be combined with reasoning = true"
));
}
let anthropic = settings
.providers
.get(&model.provider)
.is_some_and(|provider| provider.protocol == Protocol::Anthropic);
if anthropic {
problems.push(format!(
"roles.{role}.reasoning_effort: \"none\" is not available on the anthropic protocol"
));
}
},
Some(_) if !model.reasoning => {
problems.push(format!(
"roles.{role}.reasoning_effort: requires reasoning = true"
));
},
_ => {},
}
}
fn check_thinking_budget(settings: &Settings, problems: &mut Vec<String>) {
for (role, model) in settings.roles.all() {
let Some(budget) = model.thinking_budget else {
continue;
};
if !model.reasoning {
problems.push(format!(
"roles.{role}.thinking_budget: requires reasoning = true"
));
}
if model.reasoning_effort.is_some() {
problems.push(format!(
"roles.{role}.thinking_budget: cannot be combined with reasoning_effort"
));
}
let anthropic = settings
.providers
.get(&model.provider)
.is_some_and(|provider| provider.protocol == Protocol::Anthropic);
if !anthropic {
problems.push(format!(
"roles.{role}.thinking_budget: only valid on the anthropic protocol"
));
}
if budget < MIN_THINKING_BUDGET {
problems.push(format!(
"roles.{role}.thinking_budget: must be at least {MIN_THINKING_BUDGET}"
));
}
if budget >= model.max_tokens {
problems.push(format!(
"roles.{role}.thinking_budget: must be less than max_tokens"
));
}
}
}
fn check_thinking_temperature(settings: &Settings, problems: &mut Vec<String>) {
for (role, model) in settings.roles.all() {
let anthropic = settings
.providers
.get(&model.provider)
.is_some_and(|provider| provider.protocol == Protocol::Anthropic);
if anthropic && model.reasoning && model.temperature.is_some() {
problems.push(format!(
"roles.{role}.temperature: the anthropic protocol does not accept a temperature with reasoning = true"
));
}
}
}
fn check_topics(settings: &Settings, problems: &mut Vec<String>) {
let mut seen = std::collections::BTreeSet::new();
for topic in &settings.topics {
if !seen.insert(topic.name.as_str()) {
problems.push(format!("topics: duplicate name `{}`", topic.name));
}
if topic.subtopics == 0 {
problems.push(format!(
"topics.{}.subtopics: must be at least 1",
topic.name
));
}
if topic.questions_per_subtopic == 0 {
problems.push(format!(
"topics.{}.questions_per_subtopic: must be at least 1",
topic.name
));
}
}
}
fn check_pipeline(settings: &Settings, problems: &mut Vec<String>) {
let pipeline = &settings.pipeline;
if pipeline.concurrency == 0 {
problems.push("pipeline.concurrency: must be at least 1".to_string());
}
if pipeline.concurrency > MAX_CONCURRENCY {
problems.push(format!(
"pipeline.concurrency: must be at most {MAX_CONCURRENCY}"
));
}
if !(pipeline.eval_ratio > 0.0 && pipeline.eval_ratio < 1.0) {
problems.push("pipeline.eval_ratio: must be in (0, 1)".to_string());
}
if !(pipeline.dedup_threshold > 0.0 && pipeline.dedup_threshold <= 1.0) {
problems.push("pipeline.dedup_threshold: must be in (0, 1]".to_string());
}
if !(pipeline.embedding_threshold > 0.0 && pipeline.embedding_threshold <= 1.0) {
problems.push("pipeline.embedding_threshold: must be in (0, 1]".to_string());
}
if pipeline.question_batch_size == 0 {
problems.push("pipeline.question_batch_size: must be at least 1".to_string());
}
if pipeline.request_timeout_secs == 0 {
problems.push("pipeline.request_timeout_secs: must be at least 1".to_string());
}
}
const TYPED_AXOLOTL_KEYS: [(&str, &str); 16] = [
("base_model", "base_model"),
("adapter", "adapter"),
("num_epochs", "epochs"),
("learning_rate", "learning_rate"),
("lora_r", "lora_r"),
("lora_alpha", "lora_alpha"),
("lora_dropout", "lora_dropout"),
("sequence_len", "sequence_len"),
("micro_batch_size", "micro_batch_size"),
("gradient_accumulation_steps", "gradient_accumulation_steps"),
("optimizer", "optimizer"),
("lr_scheduler", "lr_scheduler"),
("sample_packing", "sample_packing"),
("evals_per_epoch", "evals_per_epoch"),
("saves_per_epoch", "saves_per_epoch"),
("hub_model_id", "hub_model_id"),
];
const MANAGED_AXOLOTL_KEYS: [&str; 7] = [
"datasets",
"test_datasets",
"val_set_size",
"output_dir",
"dataset_prepared_path",
"plugins",
"resume_from_checkpoint",
];
fn check_training(settings: &Settings, problems: &mut Vec<String>) {
let Some(training) = &settings.training else {
return;
};
if !settings.targets.contains_key(&training.target) {
problems.push(format!(
"training.target: unknown target `{}`",
training.target
));
}
check_training_counts(training, problems);
check_training_rates(training, problems);
check_axolotl_extra(training, problems);
}
fn check_compare(settings: &Settings, problems: &mut Vec<String>) {
let compare = &settings.compare;
for (key, price) in [
("parent_price_in", compare.parent_price_in),
("parent_price_out", compare.parent_price_out),
("child_price_per_hour", compare.child_price_per_hour),
] {
if price.is_some_and(|price| !price.is_finite() || price < 0.0) {
problems.push(format!(
"compare.{key}: must be a number of dollars, 0 or more"
));
}
}
if compare.max_tokens == 0 {
problems.push("compare.max_tokens: must be at least 1".to_string());
}
if !(0.0..=2.0).contains(&compare.temperature) {
problems.push("compare.temperature: must be in [0, 2]".to_string());
}
if !(1..=3600).contains(&compare.server_start_secs) {
problems.push("compare.server_start_secs: must be between 1 and 3600".to_string());
}
check_image("compare.image", compare.image.as_deref(), problems);
}
fn check_export(settings: &Settings, problems: &mut Vec<String>) {
let export = &settings.export;
if !QUANTIZE_TYPES.contains(&export.quantize.as_str()) {
problems.push(format!(
"export.quantize: unknown type `{}`; one of {}",
export.quantize,
QUANTIZE_TYPES.join(", ")
));
}
if export
.ollama_name
.as_deref()
.is_some_and(|name| !is_ollama_name(name))
{
problems.push(
"export.ollama_name: not an Ollama model name ([host/][namespace/]model[:tag], each part \
of letters, digits, '_', '-' and '.')"
.to_string(),
);
}
}
fn check_training_counts(training: &Training, problems: &mut Vec<String>) {
let counts = [
("epochs", training.epochs),
("lora_r", training.lora_r),
("lora_alpha", training.lora_alpha),
("sequence_len", training.sequence_len),
("micro_batch_size", training.micro_batch_size),
(
"gradient_accumulation_steps",
training.gradient_accumulation_steps,
),
("evals_per_epoch", training.evals_per_epoch),
("saves_per_epoch", training.saves_per_epoch),
];
for (key, value) in counts {
if value == 0 {
problems.push(format!("training.{key}: must be at least 1"));
}
}
for (key, value) in [
("optimizer", &training.optimizer),
("lr_scheduler", &training.lr_scheduler),
] {
if value.trim().is_empty() {
problems.push(format!("training.{key}: must not be empty"));
}
}
if training
.hub_model_id
.as_ref()
.is_some_and(|id| id.trim().is_empty())
{
problems.push("training.hub_model_id: must not be empty".to_string());
}
}
fn check_training_rates(training: &Training, problems: &mut Vec<String>) {
if !(training.learning_rate > 0.0 && training.learning_rate.is_finite()) {
problems.push("training.learning_rate: must be greater than 0".to_string());
}
if !(0.0..1.0).contains(&training.lora_dropout) {
problems.push("training.lora_dropout: must be in [0, 1)".to_string());
}
if training.merge && training.adapter == Adapter::Full {
problems.push(
"training.merge: requires adapter = \"lora\" or \"qlora\" (a full fine-tune has no adapter to merge)"
.to_string(),
);
}
}
fn check_axolotl_extra(training: &Training, problems: &mut Vec<String>) {
for key in training.axolotl_extra.keys() {
if let Some((_, typed)) = TYPED_AXOLOTL_KEYS.iter().find(|(name, _)| name == key) {
problems.push(format!(
"training.axolotl_extra.{key}: set training.{typed} instead"
));
}
if MANAGED_AXOLOTL_KEYS.contains(&key.as_str()) {
problems.push(format!(
"training.axolotl_extra.{key}: managed by overbrainer, cannot be overridden"
));
}
}
if training.axolotl_extra.get("save_only_model") == Some(&serde_json::Value::Bool(true)) {
problems.push(
"training.axolotl_extra.save_only_model: checkpoints without their optimizer state \
cannot be resumed after a snapshot; remove it"
.to_string(),
);
}
}
fn check_targets(settings: &Settings, problems: &mut Vec<String>) {
for (name, target) in &settings.targets {
match target {
Target::Local {
runtime,
engine,
image,
venv,
} => {
check_runtime(
name,
*runtime,
engine.is_some() || image.is_some(),
venv.is_some(),
problems,
);
check_target_image(name, image.as_deref(), problems);
check_target_venv(name, venv.as_deref(), problems);
},
Target::Ssh {
runtime,
engine,
image,
venv,
workdir,
ssh_client,
..
} => {
check_runtime(
name,
*runtime,
engine.is_some() || image.is_some(),
venv.is_some(),
problems,
);
check_target_image(name, image.as_deref(), problems);
check_target_venv(name, venv.as_deref(), problems);
check_target_workdir(name, workdir.as_deref(), problems);
check_ssh_client(name, *ssh_client, problems);
},
Target::Runpod { .. } => check_runpod_target(name, target, problems),
}
}
}
fn check_ssh_client(name: &str, client: SshClient, problems: &mut Vec<String>) {
if client == SshClient::Builtin && !HAS_BUILTIN_SSH {
problems.push(format!("targets.{name}.ssh_client: {BUILTIN_SSH_REFUSED}"));
}
}
const MAX_RUNPOD_HOURS: f64 = 720.0;
const MIN_CONTAINER_DISK_GB: u32 = 20;
const MIN_BOOT_GRACE_MINUTES: u32 = 5;
fn check_runpod_target(name: &str, target: &Target, problems: &mut Vec<String>) {
let Target::Runpod {
gpu_types,
min_vram_gb,
max_price_per_hour,
gpu_count,
image,
venv,
container_disk_gb,
max_hours,
max_cost_usd,
boot_grace_minutes,
retrieve_grace_minutes,
data_center_ids,
network_volume_id,
max_volume_gb,
ssh_client,
} = target
else {
return;
};
check_ssh_client(name, *ssh_client, problems);
check_gpu_types(name, gpu_types, problems);
check_auto_limits(name, gpu_types, *min_vram_gb, *max_price_per_hour, problems);
let minimums = [
("gpu_count", *gpu_count, 1),
(
"container_disk_gb",
*container_disk_gb,
MIN_CONTAINER_DISK_GB,
),
(
"boot_grace_minutes",
*boot_grace_minutes,
MIN_BOOT_GRACE_MINUTES,
),
("retrieve_grace_minutes", *retrieve_grace_minutes, 1),
];
for (field, value, minimum) in minimums {
if value < minimum {
problems.push(format!(
"targets.{name}.{field}: must be at least {minimum}"
));
}
}
if !(max_hours.is_finite() && *max_hours > 0.0 && *max_hours <= MAX_RUNPOD_HOURS) {
problems.push(format!(
"targets.{name}.max_hours: must be greater than 0 and at most {MAX_RUNPOD_HOURS}"
));
}
if max_cost_usd.is_some_and(|usd| !(usd.is_finite() && usd > 0.0)) {
problems.push(format!(
"targets.{name}.max_cost_usd: must be greater than 0"
));
}
match (max_volume_gb, network_volume_id) {
(Some(0), _) => problems.push(format!("targets.{name}.max_volume_gb: must be at least 1")),
(Some(_), None) => problems.push(format!(
"targets.{name}.max_volume_gb: needs network_volume_id, the only disk that can grow"
)),
_ => {},
}
check_target_image(name, image.as_deref(), problems);
check_target_venv(name, venv.as_deref(), problems);
if venv.as_deref().is_some_and(|venv| !venv.starts_with('/')) {
problems.push(format!("targets.{name}.venv: must be an absolute path"));
}
check_data_centers(
name,
data_center_ids,
network_volume_id.as_deref(),
problems,
);
}
fn check_gpu_types(name: &str, gpu_types: &ListOrAuto, problems: &mut Vec<String>) {
let ListOrAuto::List(gpu_types) = gpu_types else {
return;
};
if holds_auto(name, "gpu_types", gpu_types, problems) {
return;
}
if gpu_types.is_empty() {
problems.push(format!(
"targets.{name}.gpu_types: must list at least one GPU type"
));
}
let mut seen = std::collections::BTreeSet::new();
for gpu in gpu_types {
if gpu.is_empty() {
problems.push(format!("targets.{name}.gpu_types: an entry is empty"));
} else if gpu.contains(',') || gpu.chars().any(char::is_control) {
problems.push(format!(
"targets.{name}.gpu_types: `{gpu}` must not contain a comma or a control character"
));
} else if !seen.insert(gpu.as_str()) {
problems.push(format!("targets.{name}.gpu_types: `{gpu}` is listed twice"));
}
}
}
fn holds_auto(name: &str, field: &str, items: &[String], problems: &mut Vec<String>) -> bool {
let found = items
.iter()
.any(|item| item.eq_ignore_ascii_case(ListOrAuto::AUTO));
if found {
problems.push(format!(
"targets.{name}.{field}: write {field} = \"auto\", not a list holding it"
));
}
found
}
fn check_auto_limits(
name: &str,
gpu_types: &ListOrAuto,
min_vram_gb: Option<u32>,
max_price_per_hour: Option<f64>,
problems: &mut Vec<String>,
) {
let mut limit = |field: &str, set: bool, valid: bool, bound: &str| {
if !set {
return;
}
if !gpu_types.is_auto() {
problems.push(format!(
"targets.{name}.{field}: only with gpu_types = \"auto\""
));
} else if !valid {
problems.push(format!("targets.{name}.{field}: must be {bound}"));
}
};
limit(
"min_vram_gb",
min_vram_gb.is_some(),
min_vram_gb.is_some_and(|gb| gb >= 1),
"at least 1",
);
limit(
"max_price_per_hour",
max_price_per_hour.is_some(),
max_price_per_hour.is_some_and(|price| price.is_finite() && price > 0.0),
"greater than 0",
);
}
fn check_data_centers(
name: &str,
data_center_ids: &ListOrAuto,
network_volume_id: Option<&str>,
problems: &mut Vec<String>,
) {
if let ListOrAuto::List(ids) = data_center_ids {
check_data_center_ids(name, ids, problems);
}
let Some(volume) = network_volume_id else {
return;
};
if volume.is_empty() || !volume.chars().all(|c| c.is_ascii_alphanumeric()) {
problems.push(format!(
"targets.{name}.network_volume_id: must use only letters and digits"
));
}
if data_center_ids.is_auto() {
problems.push(format!(
"targets.{name}.data_center_ids: cannot be \"auto\" with network_volume_id: list the volume's data center"
));
} else if data_center_ids.list().len() != 1 {
problems.push(format!(
"targets.{name}.network_volume_id: needs data_center_ids with exactly one entry, the volume's data center"
));
}
}
fn check_data_center_ids(name: &str, data_center_ids: &[String], problems: &mut Vec<String>) {
if holds_auto(name, "data_center_ids", data_center_ids, problems) {
return;
}
let mut seen = std::collections::BTreeSet::new();
for id in data_center_ids {
let valid = !id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-');
if !valid {
problems.push(format!(
"targets.{name}.data_center_ids: `{id}` must use only A-Z, 0-9 and -"
));
} else if !seen.insert(id.as_str()) {
problems.push(format!(
"targets.{name}.data_center_ids: `{id}` is listed twice"
));
}
}
}
fn check_runpod(settings: &Settings, problems: &mut Vec<String>) {
check_base_url(
"runpod.base_url",
settings.runpod.base_url.as_deref(),
problems,
);
}
fn check_hub(settings: &Settings, problems: &mut Vec<String>) {
if let Some(repo) = &settings.hub.repo
&& !(repo.matches('/').count() == 1 && crate::train::sizing::is_repo_id(repo))
{
problems.push(format!("hub.repo must be NAMESPACE/NAME, got \"{repo}\""));
}
check_base_url("hub.base_url", settings.hub.base_url.as_deref(), problems);
}
fn check_base_url(key: &str, base_url: Option<&str>, problems: &mut Vec<String>) {
let Some(base_url) = base_url else {
return;
};
let allowed = url::Url::parse(base_url).is_ok_and(|url| match url.scheme() {
"https" => url.host().is_some(),
"http" => match url.host() {
Some(url::Host::Domain(domain)) => domain == "localhost",
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
},
_ => false,
});
if !allowed {
problems.push(format!(
"{key}: must be an https URL (http only on a loopback host)"
));
}
}
fn check_runtime(
name: &str,
runtime: Runtime,
container: bool,
venv: bool,
problems: &mut Vec<String>,
) {
match runtime {
Runtime::Docker if venv => {
problems.push(format!(
"targets.{name}.venv: only with runtime = \"native\""
));
},
Runtime::Native if container => problems.push(format!(
"targets.{name}: engine and image only apply with runtime = \"docker\""
)),
Runtime::Docker | Runtime::Native => {},
}
}
fn check_target_workdir(name: &str, workdir: Option<&str>, problems: &mut Vec<String>) {
let Some(workdir) = workdir else {
return;
};
if workdir.trim().is_empty() {
problems.push(format!("targets.{name}.workdir: must not be empty"));
return;
}
check_safe_path(name, "workdir", workdir, problems);
}
fn check_target_venv(name: &str, venv: Option<&str>, problems: &mut Vec<String>) {
let Some(venv) = venv else {
return;
};
if venv.trim().is_empty() {
problems.push(format!("targets.{name}.venv: must not be empty"));
return;
}
check_safe_path(name, "venv", venv, problems);
}
fn is_path_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '~' | '-')
}
fn check_safe_path(name: &str, field: &str, value: &str, problems: &mut Vec<String>) {
if !value.chars().all(is_path_char) {
problems.push(format!(
"targets.{name}.{field}: only letters, digits and . _ / ~ - are allowed"
));
return;
}
if value.starts_with('-') {
problems.push(format!("targets.{name}.{field}: must not start with -"));
}
if value.split('/').any(|segment| segment == "..") {
problems.push(format!("targets.{name}.{field}: must not contain .."));
}
if !valid_tilde_placement(value) {
problems.push(format!(
"targets.{name}.{field}: ~ is only allowed as the first character, alone or followed by /"
));
}
}
fn valid_tilde_placement(value: &str) -> bool {
match value.matches('~').count() {
0 => true,
1 => value.starts_with('~') && (value.len() == 1 || value.as_bytes()[1] == b'/'),
_ => false,
}
}
fn check_target_image(name: &str, image: Option<&str>, problems: &mut Vec<String>) {
check_image(&format!("targets.{name}.image"), image, problems);
}
fn check_image(key: &str, image: Option<&str>, problems: &mut Vec<String>) {
let Some(image) = image else {
return;
};
if image.trim().is_empty() {
problems.push(format!("{key}: must not be empty"));
return;
}
if !image.chars().all(is_image_char) {
problems.push(format!(
"{key}: only letters, digits and . _ / : @ - are allowed"
));
return;
}
if image.starts_with('-') {
problems.push(format!("{key}: must not start with -"));
}
}
fn is_image_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | ':' | '@' | '-')
}
pub(crate) fn env_only_in_file(file: &config::Config) -> Vec<String> {
let mut keys = Vec::new();
let table_names = |table: &str| -> Vec<String> {
file.get_table(table)
.map(|map| map.into_keys().collect())
.unwrap_or_default()
};
for name in table_names("providers") {
keys.push(format!("providers.{name}.base_url"));
keys.push(format!("providers.{name}.api_key"));
}
for name in table_names("targets") {
keys.push(format!("targets.{name}.host"));
}
keys.push("runpod.api_key".to_string());
keys.push("runpod.base_url".to_string());
keys.push("hub.base_url".to_string());
keys.push("hf_token".to_string());
keys.push("log".to_string());
keys.into_iter()
.filter(|key| file.get::<config::Value>(key).is_ok())
.map(|key| format!("{key}: must be set through env, not in overbrainer.toml"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(toml: &str) -> Result<Settings, config::ConfigError> {
config::Config::builder()
.add_source(config::File::from_str(toml, config::FileFormat::Toml))
.build()?
.try_deserialize()
}
const VALID: &str = r#"
[project]
name = "demo"
[[topics]]
name = "ownership"
subtopics = 3
questions_per_subtopic = 5
[providers.nanogpt]
protocol = "openai"
[roles]
generator = { provider = "nanogpt", model = "m1" }
parent = { provider = "nanogpt", model = "m2", reasoning = true }
[training]
target = "local"
base_model = "Qwen/Qwen3-4B"
adapter = "qlora"
[targets.local]
kind = "local"
runtime = "native"
"#;
#[test]
fn hub_defaults_are_private_and_manual() -> Result<(), config::ConfigError> {
let loaded = settings(VALID)?;
assert_eq!(loaded.hub, super::super::types::Hub::default());
assert!(loaded.hub.private);
assert!(!loaded.hub.after_training);
Ok(())
}
#[test]
fn hub_repo_must_be_namespace_slash_name() -> Result<(), config::ConfigError> {
for repo in ["no-slash", "a/b/c", "a/", "/b", "a b/c"] {
let toml = format!("{VALID}\n[hub]\nrepo = \"{repo}\"\n");
assert_eq!(
check(&settings(&toml)?),
vec![format!("hub.repo must be NAMESPACE/NAME, got \"{repo}\"")]
);
}
let toml = format!("{VALID}\n[hub]\nrepo = \"me/model.v1\"\n");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn hub_base_url_follows_the_runpod_rule() -> Result<(), config::ConfigError> {
let toml = format!("{VALID}\n[hub]\nbase_url = \"http://example.com\"\n");
assert_eq!(
check(&settings(&toml)?),
vec!["hub.base_url: must be an https URL (http only on a loopback host)".to_string()]
);
let toml = format!("{VALID}\n[hub]\nbase_url = \"http://127.0.0.1:8080\"\n");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn hub_model_id_is_reported_as_deprecated() -> Result<(), config::ConfigError> {
assert_eq!(deprecations(&settings(VALID)?), Vec::<String>::new());
let toml = VALID.replace(
"adapter = \"qlora\"",
"adapter = \"qlora\"\nhub_model_id = \"me/x\"",
);
assert_eq!(
deprecations(&settings(&toml)?),
vec![
"training.hub_model_id is deprecated, use [hub] repo (overbrainer migrate moves it)"
.to_string()
]
);
Ok(())
}
#[test]
fn the_legacy_token_warning_stands_down_for_the_hub_one() -> Result<(), config::ConfigError> {
let legacy = VALID.replace(
"adapter = \"qlora\"",
"adapter = \"qlora\"\nhub_model_id = \"me/x\"",
);
assert_eq!(
legacy_hub_token_warning(&settings(&legacy)?).as_deref(),
Some(
"training.hub_model_id is set but OVERBRAINER_HF_TOKEN is not: the push will fail"
)
);
let both = format!("{legacy}\n[hub]\nafter_training = true\n");
assert_eq!(legacy_hub_token_warning(&settings(&both)?), None);
assert_eq!(legacy_hub_token_warning(&settings(VALID)?), None);
Ok(())
}
#[test]
fn a_push_after_training_without_a_token_is_warned() -> Result<(), config::ConfigError> {
let on = format!("{VALID}\n[hub]\nafter_training = true\n");
assert_eq!(
hub_token_warning(&settings(&on)?).as_deref(),
Some(
"[hub] after_training is on but OVERBRAINER_HF_TOKEN is not set: the push after \
training will fail"
)
);
assert_eq!(
hub_token_warning(&settings(VALID)?),
None,
"the push is off"
);
let with_token = format!("hf_token = \"hf_x\"\n{on}");
assert_eq!(
hub_token_warning(&settings(&with_token)?),
None,
"a token is set"
);
Ok(())
}
#[test]
fn unknown_hub_keys_are_rejected() {
let toml = format!("{VALID}\n[hub]\nrepository = \"a/b\"\n");
assert!(settings(&toml).is_err());
}
#[test]
fn names_follow_the_allowed_charset() {
assert!(is_valid_name("gpu_cloud2"));
assert!(!is_valid_name(""));
assert!(!is_valid_name("gpu-cloud"));
assert!(!is_valid_name("GPU"));
}
#[test]
fn valid_settings_have_no_problems() -> Result<(), config::ConfigError> {
assert_eq!(check(&settings(VALID)?), Vec::<String>::new());
Ok(())
}
#[test]
fn compare_defaults_and_the_judge_falls_back_to_the_parent() -> Result<(), config::ConfigError>
{
let settings = settings(VALID)?;
assert_eq!(settings.roles.judge_model().model, "m2");
assert_eq!(settings.compare, crate::config::Compare::default());
assert_eq!(settings.compare.max_tokens, 4096);
assert_eq!(settings.compare.server_start_secs, 300);
assert!(settings.compare.parent_price_in.is_none());
Ok(())
}
#[test]
fn the_judge_role_is_checked_like_the_others() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
"[training]",
"judge = { provider = \"missing\", model = \"j\" }\n [training]",
);
let settings = settings(&toml)?;
assert_eq!(settings.roles.judge_model().model, "j");
assert!(
settings
.roles
.all()
.iter()
.any(|(name, _)| *name == "judge")
);
assert_eq!(
check(&settings),
vec!["roles.judge: unknown provider `missing`".to_string()]
);
Ok(())
}
#[test]
fn compare_bounds_are_checked() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n [compare]\n parent_price_in = -1.0\n \
child_price_per_hour = 0.4\n max_tokens = 0\n temperature = 2.5\n \
server_start_secs = 0\n"
);
assert_eq!(
check(&settings(&toml)?),
vec![
"compare.parent_price_in: must be a number of dollars, 0 or more".to_string(),
"compare.max_tokens: must be at least 1".to_string(),
"compare.temperature: must be in [0, 2]".to_string(),
"compare.server_start_secs: must be between 1 and 3600".to_string(),
]
);
let unknown = format!("{VALID}\n [compare]\n price = 1.0\n");
assert!(settings(&unknown).is_err(), "unknown keys are refused");
Ok(())
}
#[test]
fn the_compare_image_is_checked_like_a_target_image() -> Result<(), config::ConfigError> {
assert_eq!(settings(VALID)?.compare.image, None);
let image =
|value: &str| format!("{VALID}\n [compare]\n image = \"{value}\"\n");
let pinned = settings(&image("runpod/base:1.4.0@sha256:0123abcd"))?;
assert_eq!(
pinned.compare.image.as_deref(),
Some("runpod/base:1.4.0@sha256:0123abcd")
);
assert_eq!(check(&pinned), Vec::<String>::new());
for (value, problem) in [
(" ", "compare.image: must not be empty"),
(
"img;rm",
"compare.image: only letters, digits and . _ / : @ - are allowed",
),
("-img", "compare.image: must not start with -"),
] {
assert_eq!(check(&settings(&image(value))?), vec![problem.to_string()]);
}
Ok(())
}
#[test]
fn role_must_reference_a_known_provider() -> Result<(), config::ConfigError> {
let toml = VALID.replace(r#"model = "m1""#, r#"model = "m1", provider_x = 1"#);
assert!(
settings(&toml).is_err(),
"unknown field must be rejected by serde"
);
let toml = VALID.replace(
r#"{ provider = "nanogpt", model = "m1" }"#,
r#"{ provider = "missing", model = "m1" }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec!["roles.generator: unknown provider `missing`".to_string()]
);
Ok(())
}
#[test]
fn training_target_must_exist() -> Result<(), config::ConfigError> {
let toml = VALID.replace(r#"target = "local""#, r#"target = "cloud""#);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec!["training.target: unknown target `cloud`".to_string()]
);
Ok(())
}
fn with_runpod(extra: &str) -> String {
format!(
"{VALID}\n[targets.cloud]\nkind = \"runpod\"\ngpu_types = [\"NVIDIA A40\"]\nmax_hours = 6\n{extra}\n"
)
}
#[test]
fn a_minimal_runpod_target_is_valid_and_has_defaults() -> Result<(), Box<dyn std::error::Error>>
{
let settings = settings(&with_runpod(""))?;
assert_eq!(check(&settings), Vec::<String>::new());
match settings.targets.get("cloud") {
Some(Target::Runpod {
gpu_count,
image,
venv,
container_disk_gb,
boot_grace_minutes,
retrieve_grace_minutes,
data_center_ids,
network_volume_id,
..
}) => {
assert_eq!(*gpu_count, 1);
assert_eq!(*image, None);
assert_eq!(*venv, None);
assert_eq!(*container_disk_gb, 50);
assert_eq!(*boot_grace_minutes, 30);
assert_eq!(*retrieve_grace_minutes, 60);
assert_eq!(*data_center_ids, ListOrAuto::default());
assert_eq!(*network_volume_id, None);
},
other => return Err(format!("expected a runpod target, got {other:?}").into()),
}
Ok(())
}
#[test]
fn runpod_max_hours_must_be_in_range() -> Result<(), config::ConfigError> {
for value in ["0.0", "-1.0", "nan", "721", "inf"] {
let toml = with_runpod("").replace("max_hours = 6", &format!("max_hours = {value}"));
assert_eq!(
check(&settings(&toml)?),
vec!["targets.cloud.max_hours: must be greater than 0 and at most 720".to_string()],
"max_hours = {value}"
);
}
let toml = with_runpod("").replace("max_hours = 6", "max_hours = 720");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn runpod_gpu_types_are_checked() -> Result<(), config::ConfigError> {
let cases = [
(
"[]",
"targets.cloud.gpu_types: must list at least one GPU type",
),
(
"[\"NVIDIA A40\", \" \"]",
"targets.cloud.gpu_types: an entry is empty",
),
(
"[\"NVIDIA A40\", \"NVIDIA A40\"]",
"targets.cloud.gpu_types: `NVIDIA A40` is listed twice",
),
(
"[\"a\\u0007b\"]",
"targets.cloud.gpu_types: `a\u{7}b` must not contain a comma or a control character",
),
];
for (value, problem) in cases {
let toml = with_runpod("").replace(
"gpu_types = [\"NVIDIA A40\"]",
&format!("gpu_types = {value}"),
);
assert_eq!(
check(&settings(&toml)?),
vec![problem.to_string()],
"{value}"
);
}
Ok(())
}
#[test]
fn runpod_minimums_are_checked() -> Result<(), config::ConfigError> {
let toml = with_runpod(
"gpu_count = 0\ncontainer_disk_gb = 19\nboot_grace_minutes = 4\nretrieve_grace_minutes = 0",
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.gpu_count: must be at least 1".to_string(),
"targets.cloud.container_disk_gb: must be at least 20".to_string(),
"targets.cloud.boot_grace_minutes: must be at least 5".to_string(),
"targets.cloud.retrieve_grace_minutes: must be at least 1".to_string(),
]
);
Ok(())
}
#[test]
fn runpod_venv_must_be_absolute_and_image_safe() -> Result<(), config::ConfigError> {
let toml = with_runpod("venv = \"venvs/axolotl\"\nimage = \"img;rm\"");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.image: only letters, digits and . _ / : @ - are allowed".to_string(),
"targets.cloud.venv: must be an absolute path".to_string(),
]
);
Ok(())
}
#[test]
fn a_network_volume_needs_exactly_one_data_center() -> Result<(), config::ConfigError> {
let toml = with_runpod("network_volume_id = \"abc123\"");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.network_volume_id: needs data_center_ids with exactly one entry, the volume's data center"
.to_string()
]
);
let toml = with_runpod(
"network_volume_id = \"abc123\"\ndata_center_ids = [\"EU-RO-1\", \"US-KS-2\"]",
);
assert_eq!(check(&settings(&toml)?).len(), 1);
let toml = with_runpod("network_volume_id = \"abc123\"\ndata_center_ids = [\"EU-RO-1\"]");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn runpod_ids_use_their_charsets() -> Result<(), config::ConfigError> {
let toml = with_runpod(
"network_volume_id = \"abc-1\"\ndata_center_ids = [\"eu-ro-1\", \"EU-RO-1\", \"EU-RO-1\"]",
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.data_center_ids: `eu-ro-1` must use only A-Z, 0-9 and -".to_string(),
"targets.cloud.data_center_ids: `EU-RO-1` is listed twice".to_string(),
"targets.cloud.network_volume_id: must use only letters and digits".to_string(),
"targets.cloud.network_volume_id: needs data_center_ids with exactly one entry, the volume's data center"
.to_string(),
]
);
Ok(())
}
#[test]
fn auto_gpu_types_and_data_centers_are_valid_without_constraints()
-> Result<(), config::ConfigError> {
let toml = with_runpod("data_center_ids = \"auto\"")
.replace("gpu_types = [\"NVIDIA A40\"]", "gpu_types = \"auto\"");
let settings = settings(&toml)?;
assert_eq!(check(&settings), Vec::<String>::new());
assert!(matches!(
settings.targets.get("cloud"),
Some(Target::Runpod {
gpu_types: ListOrAuto::Auto,
data_center_ids: ListOrAuto::Auto,
min_vram_gb: None,
max_price_per_hour: None,
..
})
));
Ok(())
}
#[test]
fn auto_constraints_are_checked() -> Result<(), config::ConfigError> {
let auto = |extra: &str| {
with_runpod(extra).replace("gpu_types = [\"NVIDIA A40\"]", "gpu_types = \"auto\"")
};
let toml = auto("min_vram_gb = 48\nmax_price_per_hour = 1.5");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
let toml = auto("min_vram_gb = 0\nmax_price_per_hour = 0");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.min_vram_gb: must be at least 1".to_string(),
"targets.cloud.max_price_per_hour: must be greater than 0".to_string(),
]
);
for value in ["-1.0", "nan", "inf"] {
let toml = auto(&format!("max_price_per_hour = {value}"));
assert_eq!(
check(&settings(&toml)?),
vec!["targets.cloud.max_price_per_hour: must be greater than 0".to_string()],
"{value}"
);
}
Ok(())
}
#[test]
fn a_cost_cap_is_greater_than_0() -> Result<(), config::ConfigError> {
let toml = with_runpod("max_cost_usd = 12.5");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
assert!(matches!(
settings(&toml)?.targets.get("cloud"),
Some(Target::Runpod {
max_cost_usd: Some(12.5),
..
})
));
for value in ["0", "-1.0", "nan", "inf"] {
let toml = with_runpod(&format!("max_cost_usd = {value}"));
assert_eq!(
check(&settings(&toml)?),
vec!["targets.cloud.max_cost_usd: must be greater than 0".to_string()],
"{value}"
);
}
Ok(())
}
#[test]
fn a_volume_size_cap_needs_a_network_volume() -> Result<(), config::ConfigError> {
let volume = "network_volume_id = \"vol1\"\ndata_center_ids = [\"EU-RO-1\"]";
let toml = with_runpod(&format!("{volume}\nmax_volume_gb = 200"));
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
assert!(matches!(
settings(&toml)?.targets.get("cloud"),
Some(Target::Runpod {
max_volume_gb: Some(200),
..
})
));
assert_eq!(
check(&settings(&with_runpod("max_volume_gb = 200"))?),
vec![
"targets.cloud.max_volume_gb: needs network_volume_id, the only disk that can grow"
.to_string()
]
);
let toml = with_runpod(&format!("{volume}\nmax_volume_gb = 0"));
assert_eq!(
check(&settings(&toml)?),
vec!["targets.cloud.max_volume_gb: must be at least 1".to_string()]
);
Ok(())
}
#[test]
fn auto_constraints_need_auto_gpu_types() -> Result<(), config::ConfigError> {
let toml = with_runpod("min_vram_gb = 48\nmax_price_per_hour = 1.5");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.min_vram_gb: only with gpu_types = \"auto\"".to_string(),
"targets.cloud.max_price_per_hour: only with gpu_types = \"auto\"".to_string(),
]
);
Ok(())
}
#[test]
fn auto_inside_a_list_is_refused() -> Result<(), config::ConfigError> {
let toml = with_runpod("data_center_ids = [\"AUTO\"]")
.replace("gpu_types = [\"NVIDIA A40\"]", "gpu_types = [\"auto\"]");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.gpu_types: write gpu_types = \"auto\", not a list holding it"
.to_string(),
"targets.cloud.data_center_ids: write data_center_ids = \"auto\", not a list holding it"
.to_string(),
]
);
Ok(())
}
#[test]
fn auto_data_centers_are_refused_with_a_network_volume() -> Result<(), config::ConfigError> {
let toml = with_runpod("network_volume_id = \"abc123\"\ndata_center_ids = \"auto\"");
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.cloud.data_center_ids: cannot be \"auto\" with network_volume_id: list the volume's data center"
.to_string()
]
);
let toml = with_runpod("network_volume_id = \"abc123\"\ndata_center_ids = [\"EU-RO-1\"]")
.replace("gpu_types = [\"NVIDIA A40\"]", "gpu_types = \"auto\"");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn the_old_gpu_type_key_is_rejected() {
let toml = with_runpod("gpu_type = \"NVIDIA A40\"");
let error = settings(&toml).err().map(|error| error.to_string());
assert!(
error
.as_deref()
.is_some_and(|error| error.contains("unknown field `gpu_type`")),
"{error:?}"
);
}
#[test]
fn runtime_options_match_the_runtime() -> Result<(), config::ConfigError> {
let toml = VALID.replace(r#"runtime = "native""#, r#"runtime = "docker""#);
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"docker\"\nvenv = \"/opt/venv\"",
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.venv: only with runtime = \"native\"".to_string()]
);
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"native\"\nengine = \"podman\"",
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.local: engine and image only apply with runtime = \"docker\"".to_string()
]
);
Ok(())
}
#[cfg(not(feature = "builtin-ssh"))]
const BUILTIN_REFUSED: &str = "ssh_client = \"builtin\" needs a build with the builtin-ssh feature (the release binaries have it)";
fn with_ssh(extra: &str) -> String {
format!("{VALID}\n[targets.box]\nkind = \"ssh\"\nruntime = \"native\"\n{extra}\n")
}
#[test]
fn ssh_client_defaults_to_openssh_on_ssh_and_runpod_targets()
-> Result<(), Box<dyn std::error::Error>> {
let settings = settings(&with_ssh(""))?;
assert!(matches!(
settings.targets.get("box"),
Some(Target::Ssh {
ssh_client: SshClient::Openssh,
..
})
));
let settings = self::settings(&with_runpod(""))?;
assert!(matches!(
settings.targets.get("cloud"),
Some(Target::Runpod {
ssh_client: SshClient::Openssh,
..
})
));
Ok(())
}
#[test]
fn an_unknown_ssh_client_is_rejected() {
let error = settings(&with_ssh("ssh_client = \"putty\""))
.err()
.map(|error| error.to_string());
assert!(
error
.as_deref()
.is_some_and(|error| error.contains("putty")),
"{error:?}"
);
}
#[cfg(feature = "builtin-ssh")]
#[test]
fn builtin_is_accepted_with_the_feature() -> Result<(), config::ConfigError> {
assert_eq!(
check(&settings(&with_ssh("ssh_client = \"builtin\""))?),
Vec::<String>::new()
);
assert_eq!(
check(&settings(&with_runpod("ssh_client = \"builtin\""))?),
Vec::<String>::new()
);
Ok(())
}
#[cfg(not(feature = "builtin-ssh"))]
#[test]
fn builtin_is_refused_without_the_feature() -> Result<(), config::ConfigError> {
assert_eq!(
check(&settings(&with_ssh("ssh_client = \"builtin\""))?),
vec![format!("targets.box.ssh_client: {BUILTIN_REFUSED}")]
);
assert_eq!(
check(&settings(&with_runpod("ssh_client = \"builtin\""))?),
vec![format!("targets.cloud.ssh_client: {BUILTIN_REFUSED}")]
);
Ok(())
}
#[test]
fn ssh_workdir_must_not_be_empty() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n[targets.box]\nkind = \"ssh\"\nruntime = \"native\"\nworkdir = \" \"\n"
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.box.workdir: must not be empty".to_string()]
);
Ok(())
}
#[test]
fn target_paths_reject_unsafe_characters() -> Result<(), config::ConfigError> {
for value in [
"/opt/venv;rm",
"/opt/venv`id`",
"/opt/venv$(id)",
"/opt/venv here",
] {
let toml = VALID.replace(
r#"runtime = "native""#,
&format!("runtime = \"native\"\nvenv = \"{value}\""),
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.local.venv: only letters, digits and . _ / ~ - are allowed"
.to_string()
],
"venv = {value}"
);
}
Ok(())
}
#[test]
fn target_paths_reject_dot_dot_segments() -> Result<(), config::ConfigError> {
for value in ["/opt/../etc", "..", "foo/../bar", "../foo"] {
let toml = VALID.replace(
r#"runtime = "native""#,
&format!("runtime = \"native\"\nvenv = \"{value}\""),
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.venv: must not contain ..".to_string()],
"venv = {value}"
);
}
Ok(())
}
#[test]
fn target_paths_reject_a_leading_dash() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"native\"\nvenv = \"-rf\"",
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.venv: must not start with -".to_string()]
);
Ok(())
}
#[test]
fn target_paths_reject_a_misplaced_tilde() -> Result<(), config::ConfigError> {
for value in ["a~b", "~foo", "~/foo~"] {
let toml = VALID.replace(
r#"runtime = "native""#,
&format!("runtime = \"native\"\nvenv = \"{value}\""),
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.local.venv: ~ is only allowed as the first character, alone or followed by /"
.to_string()
],
"venv = {value}"
);
}
Ok(())
}
#[test]
fn target_paths_accept_safe_values() -> Result<(), config::ConfigError> {
for value in ["/opt/axolotl-venv", "relative/path_1.2", "~", "~/venv"] {
let toml = VALID.replace(
r#"runtime = "native""#,
&format!("runtime = \"native\"\nvenv = \"{value}\""),
);
assert_eq!(
check(&settings(&toml)?),
Vec::<String>::new(),
"venv = {value}"
);
}
Ok(())
}
#[test]
fn ssh_workdir_rejects_dot_dot_segments() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n[targets.box]\nkind = \"ssh\"\nruntime = \"native\"\nworkdir = \"/data/../etc\"\n"
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.box.workdir: must not contain ..".to_string()]
);
Ok(())
}
#[test]
fn target_image_rejects_unsafe_characters() -> Result<(), config::ConfigError> {
for value in [
"repo/img;rm",
"repo/img`id`",
"repo/img$(id)",
"repo/img here",
] {
let toml = VALID.replace(
r#"runtime = "native""#,
&format!("runtime = \"docker\"\nimage = \"{value}\""),
);
assert_eq!(
check(&settings(&toml)?),
vec![
"targets.local.image: only letters, digits and . _ / : @ - are allowed"
.to_string()
],
"image = {value}"
);
}
Ok(())
}
#[test]
fn target_image_rejects_a_leading_dash() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"docker\"\nimage = \"-x\"",
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.image: must not start with -".to_string()]
);
Ok(())
}
#[test]
fn target_image_must_not_be_empty() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"docker\"\nimage = \" \"",
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.image: must not be empty".to_string()]
);
Ok(())
}
#[test]
fn target_venv_must_not_be_empty() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"native\"\nvenv = \" \"",
);
assert_eq!(
check(&settings(&toml)?),
vec!["targets.local.venv: must not be empty".to_string()]
);
Ok(())
}
#[test]
fn target_image_accepts_a_reference_with_a_digest() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"runtime = "native""#,
"runtime = \"docker\"\nimage = \"axolotlai/axolotl:0.19.0-py3.12-cu130-2.12.1@sha256:9de7c7a5b8830480a7d2eb3b6d49759586615f5f8eb1126d5df29f8bd9fa324b\"",
);
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn training_defaults_are_set() -> Result<(), Box<dyn std::error::Error>> {
let settings = settings(VALID)?;
let training = settings.training.ok_or("training missing")?;
assert_eq!(training.lora_alpha, 32);
assert!((training.lora_dropout - 0.05).abs() < f64::EPSILON);
assert_eq!(training.micro_batch_size, 2);
assert_eq!(training.gradient_accumulation_steps, 4);
assert_eq!(training.optimizer, "adamw_torch_fused");
assert_eq!(training.lr_scheduler, "cosine");
assert!(training.sample_packing);
assert_eq!(training.evals_per_epoch, 4);
assert_eq!(training.saves_per_epoch, 1);
Ok(())
}
#[test]
fn the_export_section_is_optional_with_defaults() -> Result<(), config::ConfigError> {
let defaults = settings(VALID)?;
assert!(!defaults.export.after_training);
assert_eq!(defaults.export.quantize, "Q4_K_M");
assert_eq!(defaults.export.ollama_name, None);
let toml = format!(
"{VALID}\n[export]\nafter_training = true\nquantize = \"Q8_0\"\nollama_name = \"me/mentor:q8\"\n"
);
let set = settings(&toml)?;
assert_eq!(check(&set), Vec::<String>::new());
assert!(set.export.after_training);
assert_eq!(set.export.ollama_name.as_deref(), Some("me/mentor:q8"));
Ok(())
}
#[test]
fn export_values_are_checked() -> Result<(), config::ConfigError> {
let toml = format!("{VALID}\n[export]\nquantize = \"Q4\"\nollama_name = \"-bad name\"\n");
assert_eq!(
check(&settings(&toml)?),
vec![
"export.quantize: unknown type `Q4`; one of Q4_K_M, Q4_K_S, Q5_K_M, Q5_K_S, Q6_K, \
Q8_0, Q3_K_M, Q2_K, F16, BF16"
.to_string(),
"export.ollama_name: not an Ollama model name ([host/][namespace/]model[:tag], \
each part of letters, digits, '_', '-' and '.')"
.to_string(),
]
);
assert!(
settings(&format!("{VALID}\n[export]\nformat = \"gguf\"\n")).is_err(),
"unknown fields are refused"
);
Ok(())
}
#[test]
fn ollama_names_follow_ollama_s_rules() {
for name in [
"mentor",
"me/mentor:q4",
"mentor:latest",
"a.b_c-d",
"_x",
"registry.ollama.ai/library/mentor:q4",
"https://localhost:11434/me/mentor:v1.2",
] {
assert!(is_ollama_name(name), "{name}");
}
for name in [
"",
":tag",
"-x",
"a b",
"a:b:c",
"a:b/c",
"x;rm",
"a//b",
"a/",
"/a",
"a:",
"a/../b",
"a/b/c/d",
"my.ns/mentor",
"a/b:c:d",
] {
assert!(!is_ollama_name(name), "{name}");
}
assert!(is_ollama_name(&"a".repeat(80)));
assert!(!is_ollama_name(&"a".repeat(81)));
}
#[test]
fn training_values_are_checked() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
"adapter = \"qlora\"",
"adapter = \"full\"\nmerge = true\nepochs = 0\nmicro_batch_size = 0\nlearning_rate = -1.0\nlora_dropout = 1.0\noptimizer = \"\"\nhub_model_id = \"\"",
);
assert_eq!(
check(&settings(&toml)?),
vec![
"training.epochs: must be at least 1".to_string(),
"training.micro_batch_size: must be at least 1".to_string(),
"training.optimizer: must not be empty".to_string(),
"training.hub_model_id: must not be empty".to_string(),
"training.learning_rate: must be greater than 0".to_string(),
"training.lora_dropout: must be in [0, 1)".to_string(),
"training.merge: requires adapter = \"lora\" or \"qlora\" (a full fine-tune has no adapter to merge)".to_string(),
]
);
Ok(())
}
#[test]
fn axolotl_extra_cannot_replace_typed_or_managed_keys() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n[training.axolotl_extra]\nnum_epochs = 5\noutput_dir = \"/tmp/x\"\nwarmup_ratio = 0.05\nresume_from_checkpoint = \"/tmp/c\"\n"
);
assert_eq!(
check(&settings(&toml)?),
vec![
"training.axolotl_extra.num_epochs: set training.epochs instead".to_string(),
"training.axolotl_extra.output_dir: managed by overbrainer, cannot be overridden"
.to_string(),
"training.axolotl_extra.resume_from_checkpoint: managed by overbrainer, cannot \
be overridden"
.to_string(),
]
);
Ok(())
}
#[test]
fn a_checkpoint_without_its_optimizer_state_is_refused() -> Result<(), config::ConfigError> {
let toml = format!("{VALID}\n[training.axolotl_extra]\nsave_only_model = true\n");
assert_eq!(
check(&settings(&toml)?),
vec![
"training.axolotl_extra.save_only_model: checkpoints without their optimizer \
state cannot be resumed after a snapshot; remove it"
.to_string()
]
);
let toml = format!("{VALID}\n[training.axolotl_extra]\nsave_only_model = false\n");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn invalid_names_and_ranges_are_reported() -> Result<(), config::ConfigError> {
let toml = format!(
"{}\n[pipeline]\nconcurrency = 0\neval_ratio = 1.5\ndedup_threshold = 0.0\n",
VALID
.replace("[providers.nanogpt]", "[providers.Nano-GPT]")
.replace(r#"provider = "nanogpt""#, r#"provider = "Nano-GPT""#)
);
let problems = check(&settings(&toml)?);
assert!(problems.contains(&"providers.Nano-GPT: name must match ^[a-z0-9_]+$".to_string()));
assert!(problems.contains(&"pipeline.concurrency: must be at least 1".to_string()));
assert!(problems.contains(&"pipeline.eval_ratio: must be in (0, 1)".to_string()));
assert!(problems.contains(&"pipeline.dedup_threshold: must be in (0, 1]".to_string()));
Ok(())
}
#[test]
fn role_parameters_are_checked() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"{ provider = "nanogpt", model = "m1" }"#,
r#"{ provider = "nanogpt", model = "m1", max_tokens = 0, temperature = 2.5, reasoning_effort = "high" }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec![
"roles.generator.max_tokens: must be at least 1".to_string(),
"roles.generator.temperature: must be in [0, 2]".to_string(),
"roles.generator.reasoning_effort: requires reasoning = true".to_string(),
]
);
Ok(())
}
fn with_generator(role: &str) -> String {
VALID.replace(r#"{ provider = "nanogpt", model = "m1" }"#, role)
}
#[test]
fn effort_none_is_valid_on_the_openai_protocol_without_reasoning()
-> Result<(), config::ConfigError> {
let toml =
with_generator(r#"{ provider = "nanogpt", model = "m1", reasoning_effort = "none" }"#);
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn effort_none_cannot_combine_with_reasoning() -> Result<(), config::ConfigError> {
let toml = with_generator(
r#"{ provider = "nanogpt", model = "m1", reasoning = true, reasoning_effort = "none" }"#,
);
assert_eq!(
check(&settings(&toml)?),
vec![
"roles.generator.reasoning_effort: \"none\" cannot be combined with reasoning = true"
.to_string()
]
);
Ok(())
}
#[test]
fn effort_none_is_rejected_on_the_anthropic_protocol() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
"[roles]",
"[providers.claude]\nprotocol = \"anthropic\"\n[roles]",
);
let toml = toml.replace(
r#"{ provider = "nanogpt", model = "m1" }"#,
r#"{ provider = "claude", model = "m1", reasoning_effort = "none" }"#,
);
assert_eq!(
check(&settings(&toml)?),
vec![
"roles.generator.reasoning_effort: \"none\" is not available on the anthropic protocol"
.to_string()
]
);
Ok(())
}
#[test]
fn embedder_must_use_the_openai_protocol() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
"[roles]",
"[providers.claude]\nprotocol = \"anthropic\"\n[roles]\nembedder = { provider = \"claude\", model = \"e\" }",
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec![
"roles.embedder: the anthropic protocol has no embeddings, use an openai provider"
.to_string()
]
);
Ok(())
}
#[test]
fn role_parameters_have_defaults() -> Result<(), config::ConfigError> {
let settings = settings(VALID)?;
assert_eq!(settings.roles.parent.max_tokens, 16_384);
assert_eq!(settings.roles.parent.temperature, None);
assert_eq!(settings.roles.parent.reasoning_effort, None);
assert_eq!(settings.pipeline.question_batch_size, 10);
assert_eq!(settings.pipeline.request_timeout_secs, 600);
Ok(())
}
#[test]
fn new_pipeline_fields_are_checked() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n[pipeline]\nembedding_threshold = 1.5\nquestion_batch_size = 0\nrequest_timeout_secs = 0\n"
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec![
"pipeline.embedding_threshold: must be in (0, 1]".to_string(),
"pipeline.question_batch_size: must be at least 1".to_string(),
"pipeline.request_timeout_secs: must be at least 1".to_string(),
]
);
Ok(())
}
#[test]
fn concurrency_is_capped() -> Result<(), config::ConfigError> {
let toml = format!("{VALID}\n[pipeline]\nconcurrency = 1025\n");
assert_eq!(
check(&settings(&toml)?),
vec!["pipeline.concurrency: must be at most 1024".to_string()]
);
let toml = format!("{VALID}\n[pipeline]\nconcurrency = 1024\n");
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn temperature_with_reasoning_is_rejected_on_the_anthropic_protocol()
-> Result<(), config::ConfigError> {
let with_claude = |parent: &str| {
VALID.replace(
r#"parent = { provider = "nanogpt", model = "m2", reasoning = true }"#,
&format!("parent = {parent}\n[providers.claude]\nprotocol = \"anthropic\""),
)
};
let toml = with_claude(
r#"{ provider = "claude", model = "m2", reasoning = true, temperature = 0.7 }"#,
);
assert_eq!(
check(&settings(&toml)?),
vec![
"roles.parent.temperature: the anthropic protocol does not accept a temperature with reasoning = true"
.to_string()
]
);
for parent in [
r#"{ provider = "claude", model = "m2", temperature = 0.7 }"#,
r#"{ provider = "claude", model = "m2", reasoning = true }"#,
r#"{ provider = "nanogpt", model = "m2", reasoning = true, temperature = 0.7 }"#,
] {
assert_eq!(
check(&settings(&with_claude(parent))?),
Vec::<String>::new(),
"{parent}"
);
}
Ok(())
}
fn with_anthropic_generator(generator: &str) -> String {
format!(
"{}\n[providers.claude]\nprotocol = \"anthropic\"",
VALID.replace(
r#"generator = { provider = "nanogpt", model = "m1" }"#,
&format!("generator = {generator}"),
)
)
}
#[test]
fn thinking_budget_requires_reasoning() -> Result<(), config::ConfigError> {
let toml = with_anthropic_generator(
r#"{ provider = "claude", model = "m1", thinking_budget = 2048 }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec!["roles.generator.thinking_budget: requires reasoning = true".to_string()]
);
Ok(())
}
#[test]
fn thinking_budget_has_a_minimum() -> Result<(), config::ConfigError> {
let toml = with_anthropic_generator(
r#"{ provider = "claude", model = "m1", reasoning = true, thinking_budget = 512, max_tokens = 4096 }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec!["roles.generator.thinking_budget: must be at least 1024".to_string()]
);
Ok(())
}
#[test]
fn thinking_budget_must_be_below_max_tokens() -> Result<(), config::ConfigError> {
let toml = with_anthropic_generator(
r#"{ provider = "claude", model = "m1", reasoning = true, thinking_budget = 4096, max_tokens = 4096 }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec!["roles.generator.thinking_budget: must be less than max_tokens".to_string()]
);
Ok(())
}
#[test]
fn thinking_budget_cannot_combine_with_reasoning_effort() -> Result<(), config::ConfigError> {
let toml = with_anthropic_generator(
r#"{ provider = "claude", model = "m1", reasoning = true, thinking_budget = 2048, reasoning_effort = "high", max_tokens = 4096 }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec![
"roles.generator.thinking_budget: cannot be combined with reasoning_effort"
.to_string()
]
);
Ok(())
}
#[test]
fn thinking_budget_is_rejected_on_the_openai_protocol() -> Result<(), config::ConfigError> {
let toml = VALID.replace(
r#"generator = { provider = "nanogpt", model = "m1" }"#,
r#"generator = { provider = "nanogpt", model = "m1", reasoning = true, thinking_budget = 2048 }"#,
);
let problems = check(&settings(&toml)?);
assert_eq!(
problems,
vec![
"roles.generator.thinking_budget: only valid on the anthropic protocol".to_string()
]
);
Ok(())
}
#[test]
fn thinking_budget_is_accepted_when_valid() -> Result<(), config::ConfigError> {
let toml = with_anthropic_generator(
r#"{ provider = "claude", model = "m1", reasoning = true, thinking_budget = 2048, max_tokens = 4096 }"#,
);
assert_eq!(check(&settings(&toml)?), Vec::<String>::new());
Ok(())
}
#[test]
fn topics_must_be_unique_and_non_zero() -> Result<(), config::ConfigError> {
let toml = format!(
"{VALID}\n[[topics]]\nname = \"ownership\"\nsubtopics = 0\nquestions_per_subtopic = 1\n"
);
let problems = check(&settings(&toml)?);
assert!(problems.contains(&"topics: duplicate name `ownership`".to_string()));
assert!(problems.contains(&"topics.ownership.subtopics: must be at least 1".to_string()));
Ok(())
}
}