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,
}
#[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) ctx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) kv_cache_budget: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) kv_persist_budget: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ConfiguredScheduler {
FifoSerial,
InflightBatched,
}
pub(crate) const GUIDE_SCHEDULER: ConfiguredScheduler = ConfiguredScheduler::InflightBatched;
pub(crate) const GUIDE_MAX_SLOTS: u32 = 4;
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 serve = ServeDefaultsV2 {
host: "127.0.0.1".to_owned(),
port: 8081,
scheduler: GUIDE_SCHEDULER,
max_slots: GUIDE_MAX_SLOTS,
ctx: None,
kv_cache_budget: None,
kv_persist_budget: None,
};
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()))?;
let document: toml::Value = toml::from_str(text)
.map_err(|error| SetupError::InvalidConfig(format!("invalid TOML: {error}")))?;
reject_unsupported_schema(&document)?;
let document = retire_serve_behavior_keys(document);
let config: Self = document
.try_into()
.map_err(|error| SetupError::InvalidConfig(format!("invalid config.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 {
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(),
));
}
crate::serve::operator_settings::validate_max_slots(self.max_slots)
.map_err(|error| SetupError::InvalidConfig(format!("serve.{error}")))?;
if self.ctx == Some(0) {
return Err(SetupError::InvalidConfig(
"serve.ctx must be positive; omit it to use the GGUF maximum".to_owned(),
));
}
if let Some(value) = self.kv_cache_budget.as_deref() {
crate::serve::operator_settings::parse_byte_size(value).map_err(|error| {
SetupError::InvalidConfig(format!("serve.kv_cache_budget is invalid: {error}"))
})?;
}
if let Some(value) = self.kv_persist_budget.as_deref() {
crate::serve::operator_settings::parse_byte_size(value).map_err(|error| {
SetupError::InvalidConfig(format!("serve.kv_persist_budget is invalid: {error}"))
})?;
}
Ok(())
}
}
fn retire_serve_behavior_keys(mut document: toml::Value) -> toml::Value {
const RETIRED_SERVE_KEYS: [(&str, &str); 3] = [
("repetition_penalty", "--default-repetition-penalty"),
("thinking_token_budget", "--default-thinking-token-budget"),
(
"tool_thinking_token_budget",
"--default-tool-thinking-token-budget",
),
];
let Some(serve) = document
.get_mut("serve")
.and_then(toml::Value::as_table_mut)
else {
return document;
};
for (key, flag) in RETIRED_SERVE_KEYS {
if let Some(value) = serve.remove(key) {
eprintln!(
"warning: config.toml `serve.{key} = {value}` is retired and ignored \
(ADR-062 D1); serving uses the family built-in, and \
`hf2q serve {flag}` remains the explicit override"
);
}
}
document
}
fn reject_unsupported_schema(document: &toml::Value) -> Result<(), SetupError> {
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(),
)),
}
}