use std::collections::BTreeMap;
use std::fmt;
use std::marker::PhantomData;
use std::net::SocketAddr;
use std::str::FromStr;
use secrecy::SecretString;
use serde::de::{self, Unexpected, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub project: Project,
#[serde(default)]
pub topics: Vec<Topic>,
#[serde(default)]
pub providers: BTreeMap<String, Provider>,
pub roles: Roles,
#[serde(default)]
pub pipeline: Pipeline,
pub training: Option<Training>,
#[serde(default)]
pub targets: BTreeMap<String, Target>,
#[serde(default)]
pub runpod: Runpod,
#[serde(default)]
pub export: Export,
#[serde(default)]
pub compare: Compare,
#[serde(default)]
pub hub: Hub,
#[serde(default)]
pub metrics: Metrics,
pub hf_token: Option<SecretString>,
pub log: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Topic {
pub name: String,
pub description: Option<String>,
pub subtopics: u32,
pub questions_per_subtopic: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Openai,
Anthropic,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Provider {
pub protocol: Protocol,
pub base_url: Option<String>,
pub api_key: Option<SecretString>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Roles {
pub generator: RoleModel,
pub parent: RoleModel,
pub embedder: Option<RoleModel>,
pub judge: Option<RoleModel>,
}
impl Roles {
#[must_use]
pub fn all(&self) -> Vec<(&'static str, &RoleModel)> {
let mut roles = vec![("generator", &self.generator), ("parent", &self.parent)];
if let Some(embedder) = &self.embedder {
roles.push(("embedder", embedder));
}
if let Some(judge) = &self.judge {
roles.push(("judge", judge));
}
roles
}
#[must_use]
pub fn judge_model(&self) -> &RoleModel {
self.judge.as_ref().unwrap_or(&self.parent)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleModel {
pub provider: String,
pub model: String,
#[serde(default)]
pub reasoning: bool,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
pub temperature: Option<f64>,
pub reasoning_effort: Option<Effort>,
pub thinking_budget: Option<u32>,
}
fn default_max_tokens() -> u32 {
16_384
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
None,
Low,
Medium,
High,
}
impl Effort {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Pipeline {
pub concurrency: usize,
pub max_retries: u32,
pub dedup_threshold: f64,
pub eval_ratio: f64,
pub seed: u64,
pub include_system_prompt: bool,
pub embedding_threshold: f64,
pub question_batch_size: u32,
pub request_timeout_secs: u64,
}
impl Default for Pipeline {
fn default() -> Self {
Self {
concurrency: 8,
max_retries: 5,
dedup_threshold: 0.8,
eval_ratio: 0.1,
seed: 42,
include_system_prompt: false,
embedding_threshold: 0.9,
question_batch_size: 10,
request_timeout_secs: 600,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Adapter {
Lora,
Qlora,
Full,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Training {
pub target: String,
pub base_model: String,
pub adapter: Adapter,
#[serde(default = "default_epochs")]
pub epochs: u32,
#[serde(default = "default_learning_rate")]
pub learning_rate: f64,
#[serde(default = "default_lora_r")]
pub lora_r: u32,
#[serde(default = "default_lora_alpha")]
pub lora_alpha: u32,
#[serde(default = "default_lora_dropout")]
pub lora_dropout: f64,
#[serde(default = "default_sequence_len")]
pub sequence_len: u32,
#[serde(default = "default_micro_batch_size")]
pub micro_batch_size: u32,
#[serde(default = "default_gradient_accumulation_steps")]
pub gradient_accumulation_steps: u32,
#[serde(default = "default_optimizer")]
pub optimizer: String,
#[serde(default = "default_lr_scheduler")]
pub lr_scheduler: String,
#[serde(default = "default_sample_packing")]
pub sample_packing: bool,
#[serde(default = "default_evals_per_epoch")]
pub evals_per_epoch: u32,
#[serde(default = "default_saves_per_epoch")]
pub saves_per_epoch: u32,
#[serde(default)]
pub merge: bool,
pub hub_model_id: Option<String>,
#[serde(default)]
pub axolotl_extra: BTreeMap<String, serde_json::Value>,
}
fn default_epochs() -> u32 {
3
}
fn default_learning_rate() -> f64 {
2e-4
}
fn default_lora_r() -> u32 {
16
}
fn default_lora_alpha() -> u32 {
32
}
fn default_lora_dropout() -> f64 {
0.05
}
fn default_sequence_len() -> u32 {
4096
}
fn default_micro_batch_size() -> u32 {
2
}
fn default_gradient_accumulation_steps() -> u32 {
4
}
fn default_optimizer() -> String {
"adamw_torch_fused".to_string()
}
fn default_lr_scheduler() -> String {
"cosine".to_string()
}
fn default_sample_packing() -> bool {
true
}
fn default_evals_per_epoch() -> u32 {
4
}
fn default_saves_per_epoch() -> u32 {
1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Runtime {
Docker,
Native,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Engine {
Docker,
Podman,
}
impl Engine {
#[must_use]
pub fn command(self) -> &'static str {
match self {
Self::Docker => "docker",
Self::Podman => "podman",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SshClient {
#[default]
Openssh,
Builtin,
}
impl SshClient {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Openssh => "openssh",
Self::Builtin => "builtin",
}
}
}
pub const SSH_CLIENT_ENV: &str = "OVERBRAINER_SSH_CLIENT";
pub const BUILTIN_SSH_REFUSED: &str = "ssh_client = \"builtin\" needs a build with the builtin-ssh feature (the release binaries have it)";
const BUILTIN_SSH_ENV_REFUSED: &str = "OVERBRAINER_SSH_CLIENT=builtin needs a build with the builtin-ssh feature (the release binaries have it)";
pub const HAS_BUILTIN_SSH: bool = cfg!(feature = "builtin-ssh");
pub fn effective_client(configured: SshClient, env: Option<&str>) -> Result<SshClient, String> {
let env = env.map(str::trim).filter(|value| !value.is_empty());
let client = match env {
None => configured,
Some("openssh") => SshClient::Openssh,
Some("builtin") => SshClient::Builtin,
Some(_) => {
return Err(format!("{SSH_CLIENT_ENV} must be openssh or builtin"));
},
};
if client == SshClient::Builtin && !HAS_BUILTIN_SSH {
let refusal = if env.is_some() {
BUILTIN_SSH_ENV_REFUSED
} else {
BUILTIN_SSH_REFUSED
};
return Err(refusal.to_string());
}
Ok(client)
}
pub const DEFAULT_IMAGE: &str = "axolotlai/axolotl:0.21.0-py3.12-cu130-2.14.1@sha256:d08da8a5dd2b3223a49fb25cdadd85bdf0c5acee9c5de223bf6ab10d0b511e14";
pub const DEFAULT_WORKDIR: &str = "overbrainer";
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum Target {
Local {
runtime: Runtime,
engine: Option<Engine>,
image: Option<String>,
venv: Option<String>,
},
Ssh {
runtime: Runtime,
host: Option<String>,
workdir: Option<String>,
engine: Option<Engine>,
image: Option<String>,
venv: Option<String>,
#[serde(default)]
ssh_client: SshClient,
},
Runpod {
gpu_types: ListOrAuto,
#[serde(default, deserialize_with = "optional_number")]
min_vram_gb: Option<u32>,
#[serde(default, deserialize_with = "optional_number")]
max_price_per_hour: Option<f64>,
#[serde(default = "default_gpu_count", deserialize_with = "number")]
gpu_count: u32,
image: Option<String>,
venv: Option<String>,
#[serde(default = "default_container_disk_gb", deserialize_with = "number")]
container_disk_gb: u32,
#[serde(deserialize_with = "number")]
max_hours: f64,
#[serde(default, deserialize_with = "optional_number")]
max_cost_usd: Option<f64>,
#[serde(default = "default_boot_grace_minutes", deserialize_with = "number")]
boot_grace_minutes: u32,
#[serde(
default = "default_retrieve_grace_minutes",
deserialize_with = "number"
)]
retrieve_grace_minutes: u32,
#[serde(default)]
data_center_ids: ListOrAuto,
network_volume_id: Option<String>,
#[serde(default, deserialize_with = "optional_number")]
max_volume_gb: Option<u32>,
#[serde(default)]
ssh_client: SshClient,
},
}
pub const DEFAULT_RUNPOD_IMAGE: &str = "axolotlai/axolotl-cloud-term:0.21.0-py3.12-cu130-2.14.1@sha256:6dd02035f432a81725fc7405a525f6539a7bc9e40ac91702188170800b4415c1";
pub const DEFAULT_RUNPOD_VENV: &str = "/workspace/axolotl-venv";
pub const DEFAULT_RUNPOD_BASE_URL: &str = "https://api.runpod.io/v2";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListOrAuto {
Auto,
List(Vec<String>),
}
impl ListOrAuto {
pub const AUTO: &'static str = "auto";
#[must_use]
pub fn is_auto(&self) -> bool {
matches!(self, Self::Auto)
}
#[must_use]
pub fn list(&self) -> &[String] {
match self {
Self::Auto => &[],
Self::List(items) => items,
}
}
#[must_use]
pub fn is_any(&self) -> bool {
*self == Self::default()
}
#[must_use]
pub fn from_form_text(text: &str) -> Self {
if text.trim() == Self::AUTO {
return Self::Auto;
}
Self::List(
text.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect(),
)
}
}
impl Default for ListOrAuto {
fn default() -> Self {
Self::List(Vec::new())
}
}
impl fmt::Display for ListOrAuto {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => formatter.write_str(Self::AUTO),
Self::List(items) => formatter.write_str(&items.join(", ")),
}
}
}
impl<'de> Deserialize<'de> for ListOrAuto {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ListVisitor;
impl<'de> Visitor<'de> for ListVisitor {
type Value = ListOrAuto;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("\"auto\", a list of strings or a comma-separated string")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<ListOrAuto, E> {
let value = value.trim();
if value == ListOrAuto::AUTO {
return Ok(ListOrAuto::Auto);
}
if value.is_empty() {
return Ok(ListOrAuto::default());
}
Ok(ListOrAuto::List(
value
.split(',')
.map(|item| item.trim().to_string())
.collect(),
))
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<ListOrAuto, A::Error> {
let mut items = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
items.push(item.trim().to_string());
}
Ok(ListOrAuto::List(items))
}
}
deserializer.deserialize_any(ListVisitor)
}
}
fn optional_number<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
{
number(deserializer).map(Some)
}
fn number<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
{
struct NumberVisitor<T>(PhantomData<T>);
impl<T: FromStr> NumberVisitor<T> {
fn parse<E: de::Error>(&self, text: &str, unexpected: Unexpected<'_>) -> Result<T, E> {
text.trim()
.parse()
.map_err(|_| E::invalid_value(unexpected, self))
}
}
impl<T: FromStr> Visitor<'_> for NumberVisitor<T> {
type Value = T;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a number")
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<T, E> {
self.parse(&value.to_string(), Unexpected::Unsigned(value))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<T, E> {
self.parse(&value.to_string(), Unexpected::Signed(value))
}
fn visit_f64<E: de::Error>(self, value: f64) -> Result<T, E> {
self.parse(&value.to_string(), Unexpected::Float(value))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<T, E> {
self.parse(value, Unexpected::Str(value))
}
}
deserializer.deserialize_any(NumberVisitor(PhantomData))
}
fn default_gpu_count() -> u32 {
1
}
fn default_container_disk_gb() -> u32 {
50
}
fn default_boot_grace_minutes() -> u32 {
30
}
fn default_retrieve_grace_minutes() -> u32 {
60
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Runpod {
pub api_key: Option<SecretString>,
pub base_url: Option<String>,
}
pub const QUANTIZE_TYPES: [&str; 10] = [
"Q4_K_M", "Q4_K_S", "Q5_K_M", "Q5_K_S", "Q6_K", "Q8_0", "Q3_K_M", "Q2_K", "F16", "BF16",
];
pub const DEFAULT_QUANTIZE: &str = "Q4_K_M";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Export {
#[serde(default)]
pub after_training: bool,
#[serde(default = "default_quantize")]
pub quantize: String,
pub ollama_name: Option<String>,
}
impl Default for Export {
fn default() -> Self {
Self {
after_training: false,
quantize: default_quantize(),
ollama_name: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Compare {
pub parent_price_in: Option<f64>,
pub parent_price_out: Option<f64>,
pub child_price_per_hour: Option<f64>,
pub max_tokens: u32,
pub temperature: f64,
pub server_start_secs: u64,
pub image: Option<String>,
}
impl Default for Compare {
fn default() -> Self {
Self {
parent_price_in: None,
parent_price_out: None,
child_price_per_hour: None,
max_tokens: 4096,
temperature: 0.0,
server_start_secs: 300,
image: None,
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Hub {
#[serde(default)]
pub repo: Option<String>,
#[serde(default = "default_true")]
pub private: bool,
#[serde(default)]
pub after_training: bool,
#[serde(default)]
pub base_url: Option<String>,
}
impl Default for Hub {
fn default() -> Self {
Self {
repo: None,
private: true,
after_training: false,
base_url: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_quantize() -> String {
DEFAULT_QUANTIZE.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OllamaPart {
Host,
Namespace,
Model,
Tag,
}
fn ollama_part(kind: OllamaPart, text: &str) -> bool {
let max = if kind == OllamaPart::Host { 350 } else { 80 };
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
(1..=max).contains(&text.len())
&& text.starts_with(word)
&& text.chars().all(|c| {
word(c)
|| c == '-'
|| (c == '.' && kind != OllamaPart::Namespace)
|| (c == ':' && kind == OllamaPart::Host)
})
}
#[must_use]
pub fn is_ollama_name(name: &str) -> bool {
let (rest, tag) = match (name.rfind(':'), name.rfind('/')) {
(Some(colon), slash) if slash.is_none_or(|slash| colon > slash) => {
(&name[..colon], Some(&name[colon + 1..]))
},
_ => (name, None),
};
let (rest, model) = match rest.rsplit_once('/') {
Some((rest, model)) => (Some(rest), model),
None => (None, rest),
};
let (host, namespace) = match rest.map(|rest| rest.rsplit_once('/')) {
None => (None, None),
Some(None) => (None, rest),
Some(Some((host, namespace))) => (Some(host), Some(namespace)),
};
let host = host.map(|host| host.split_once("://").map_or(host, |(_, host)| host));
ollama_part(OllamaPart::Model, model)
&& tag.is_none_or(|tag| ollama_part(OllamaPart::Tag, tag))
&& namespace.is_none_or(|namespace| ollama_part(OllamaPart::Namespace, namespace))
&& host.is_none_or(|host| ollama_part(OllamaPart::Host, host))
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Metrics {
pub listen: Option<SocketAddr>,
}
#[cfg(test)]
mod tests {
use super::*;
#[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)";
#[test]
fn effective_client_lets_the_environment_win() {
assert_eq!(
effective_client(SshClient::Openssh, None),
Ok(SshClient::Openssh)
);
assert_eq!(
effective_client(SshClient::Builtin, Some("openssh")),
Ok(SshClient::Openssh)
);
assert_eq!(
effective_client(SshClient::Builtin, Some("")),
effective_client(SshClient::Builtin, None)
);
}
#[cfg(feature = "builtin-ssh")]
#[test]
fn effective_client_accepts_builtin_with_the_feature() {
assert_eq!(
effective_client(SshClient::Openssh, Some("builtin")),
Ok(SshClient::Builtin)
);
assert_eq!(
effective_client(SshClient::Builtin, None),
Ok(SshClient::Builtin)
);
}
#[cfg(not(feature = "builtin-ssh"))]
#[test]
fn effective_client_refuses_builtin_without_the_feature() {
assert_eq!(
effective_client(SshClient::Openssh, Some("builtin")),
Err("OVERBRAINER_SSH_CLIENT=builtin needs a build with the builtin-ssh feature (the release binaries have it)".to_string())
);
assert_eq!(
effective_client(SshClient::Builtin, None),
Err(BUILTIN_REFUSED.to_string())
);
}
#[test]
fn effective_client_names_the_variable_on_an_invalid_value() {
let error = effective_client(SshClient::Openssh, Some("putty"));
assert!(
error
.as_ref()
.err()
.is_some_and(|error| error.contains("OVERBRAINER_SSH_CLIENT")
&& error.contains("openssh")
&& error.contains("builtin")),
"{error:?}"
);
}
}