use serde::{Deserialize, Serialize};
use super::SetupError;
pub(super) const MAX_CONFIG_BYTES: usize = 16 * 1024;
const CONFIG_KIND: &str = "hf2q.config";
const CONFIG_SCHEMA_VERSION: i64 = 2;
const PACKAGE: &str = "hf2q";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OperatorConfigV2 {
kind: String,
schema_version: u32,
package: String,
pub(crate) convert: ConvertDefaultsV2,
pub(crate) serve: ServeDefaultsV2,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ConvertDefaultsV2 {
pub(crate) quant: String,
}
pub(crate) const AGENTIC_PROFILE_REPETITION_PENALTY: f64 = 1.05;
pub(crate) const AGENTIC_PROFILE_THINKING_BUDGET: u32 = 2048;
pub(crate) const AGENTIC_PROFILE_TOOL_THINKING_BUDGET: u32 = 512;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServeDefaultsV2 {
pub(crate) host: String,
pub(crate) port: u16,
pub(crate) scheduler: ConfiguredScheduler,
pub(crate) max_slots: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) repetition_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) thinking_token_budget: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) tool_thinking_token_budget: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ConfiguredScheduler {
FifoSerial,
InflightBatched,
}
impl ConfiguredScheduler {
pub(crate) const fn as_cli(self) -> crate::cli::SchedulerArg {
match self {
Self::FifoSerial => crate::cli::SchedulerArg::FifoSerial,
Self::InflightBatched => crate::cli::SchedulerArg::InflightBatched,
}
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::FifoSerial => "fifo_serial",
Self::InflightBatched => "inflight_batched",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ConfiguredShell {
Bash,
Fish,
Zsh,
Other,
}
impl ConfiguredShell {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Fish => "fish",
Self::Zsh => "zsh",
Self::Other => "other",
}
}
}
impl OperatorConfigV2 {
pub(crate) fn new(
convert: ConvertDefaultsV2,
serve: ServeDefaultsV2,
) -> Result<Self, SetupError> {
let config = Self {
kind: CONFIG_KIND.to_owned(),
schema_version: CONFIG_SCHEMA_VERSION as u32,
package: PACKAGE.to_owned(),
convert,
serve,
};
config.validate()?;
Ok(config)
}
pub(crate) fn guide_defaults() -> Result<Self, SetupError> {
let mut serve = ServeDefaultsV2 {
host: "127.0.0.1".to_owned(),
port: 8081,
scheduler: ConfiguredScheduler::InflightBatched,
max_slots: 1,
repetition_penalty: None,
thinking_token_budget: None,
tool_thinking_token_budget: None,
};
serve.apply_agentic_profile();
Self::new(
ConvertDefaultsV2 {
quant: "q4_k_m".to_owned(),
},
serve,
)
}
pub(crate) fn parse(bytes: &[u8]) -> Result<Self, SetupError> {
if bytes.is_empty() || bytes.len() > MAX_CONFIG_BYTES {
return Err(SetupError::InvalidConfig(
"config.toml is empty or exceeds 16 KiB".to_owned(),
));
}
let text = std::str::from_utf8(bytes)
.map_err(|_| SetupError::InvalidConfig("config.toml is not UTF-8".to_owned()))?;
reject_unsupported_schema(text)?;
let config: Self = toml::from_str(text)
.map_err(|error| SetupError::InvalidConfig(format!("invalid TOML: {error}")))?;
config.validate()?;
Ok(config)
}
pub(crate) fn to_canonical_bytes(&self) -> Result<Vec<u8>, SetupError> {
self.validate()?;
let mut text = toml::to_string(self)
.map_err(|error| SetupError::InvalidConfig(format!("cannot encode TOML: {error}")))?;
if !text.ends_with('\n') {
text.push('\n');
}
if text.len() > MAX_CONFIG_BYTES {
return Err(SetupError::InvalidConfig(
"encoded config.toml exceeds 16 KiB".to_owned(),
));
}
if Self::parse(text.as_bytes())? != *self {
return Err(SetupError::InvalidConfig(
"config.toml producer did not round-trip exactly".to_owned(),
));
}
Ok(text.into_bytes())
}
fn validate(&self) -> Result<(), SetupError> {
if self.kind != CONFIG_KIND
|| self.schema_version != CONFIG_SCHEMA_VERSION as u32
|| self.package != PACKAGE
{
return Err(SetupError::InvalidConfig(
"config identity or schema is unsupported".to_owned(),
));
}
self.convert.validate()?;
self.serve.validate()
}
}
impl ConvertDefaultsV2 {
fn validate(&self) -> Result<(), SetupError> {
crate::convert::QuantSelector::from_name(&self.quant)
.map(|_| ())
.map_err(|error| {
SetupError::InvalidConfig(format!(
"convert.quant is not a supported hf2q quant selector: {error}"
))
})
}
}
impl ServeDefaultsV2 {
pub(crate) fn apply_agentic_profile(&mut self) {
self.repetition_penalty = Some(AGENTIC_PROFILE_REPETITION_PENALTY);
self.thinking_token_budget = Some(AGENTIC_PROFILE_THINKING_BUDGET);
self.tool_thinking_token_budget = Some(AGENTIC_PROFILE_TOOL_THINKING_BUDGET);
}
pub(crate) fn clear_agentic_profile(&mut self) {
self.repetition_penalty = None;
self.thinking_token_budget = None;
self.tool_thinking_token_budget = None;
}
fn validate(&self) -> Result<(), SetupError> {
if !matches!(self.host.as_str(), "127.0.0.1" | "0.0.0.0") {
return Err(SetupError::InvalidConfig(
"serve.host must be 127.0.0.1 or 0.0.0.0".to_owned(),
));
}
if self.port == 0 {
return Err(SetupError::InvalidConfig(
"serve.port must be in 1..=65535".to_owned(),
));
}
if self.max_slots == 0 {
return Err(SetupError::InvalidConfig(
"serve.max_slots must be positive".to_owned(),
));
}
if self.scheduler == ConfiguredScheduler::FifoSerial && self.max_slots != 1 {
return Err(SetupError::InvalidConfig(
"serve.max_slots must be 1 for fifo_serial".to_owned(),
));
}
if let Some(penalty) = self.repetition_penalty {
if !penalty.is_finite() || penalty <= 0.0 {
return Err(SetupError::InvalidConfig(
"serve.repetition_penalty must be a finite positive number".to_owned(),
));
}
}
Ok(())
}
}
fn reject_unsupported_schema(text: &str) -> Result<(), SetupError> {
let document: toml::Value = toml::from_str(text)
.map_err(|error| SetupError::InvalidConfig(format!("invalid TOML: {error}")))?;
let table = document.as_table().ok_or_else(|| {
SetupError::InvalidConfig("config.toml must contain a TOML table".to_owned())
})?;
let kind = table.get("kind").and_then(toml::Value::as_str);
let version = table
.get("schema_version")
.and_then(toml::Value::as_integer);
if kind != Some(CONFIG_KIND) {
return Err(SetupError::InvalidConfig(
"config kind is unsupported".to_owned(),
));
}
match version {
Some(CONFIG_SCHEMA_VERSION) => Ok(()),
Some(1) => Err(SetupError::InvalidConfig(
"provisional config schema 1 is no longer supported; move config.toml aside and rerun `hf2q setup`"
.to_owned(),
)),
Some(other) => Err(SetupError::InvalidConfig(format!(
"config schema {other} is unsupported; upgrade hf2q or rerun `hf2q setup`"
))),
None => Err(SetupError::InvalidConfig(
"config schema_version is missing or not an integer".to_owned(),
)),
}
}