Move preferences from the database to a YAML config file

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-26 09:50:39 +02:00
parent 3f2c42513f
commit 5d441038bc
19 changed files with 571 additions and 964 deletions

View File

@@ -44,104 +44,60 @@ pub(super) struct PreferenceDraft {
}
impl PreferenceDraft {
pub(super) fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
let model = ModelChoice::from_id(&preferences.selected_model)
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
let generation = preferences.generation()?;
let runtime = preferences.runtime()?;
runtime.validate(model)?;
pub(super) fn from_saved(config: &Config) -> Self {
let generation = &config.generation;
let runtime = &config.runtime;
let execution = &runtime.execution;
let speculative = &runtime.speculative;
Ok(Self {
model,
Self {
model: config.model,
dspark_enabled: speculative.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
endpoint_port: preferences.endpoint_port.to_string(),
endpoint_enabled: preferences.endpoint_enabled,
endpoint_cors: preferences.endpoint_cors,
idle_timeout_minutes: config.idle_timeout_minutes.to_string(),
endpoint_port: config.endpoint.port.to_string(),
endpoint_enabled: config.endpoint.enabled,
endpoint_cors: config.endpoint.cors,
context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: generation.system_prompt,
temperature: generation
.temperature
.map_or_else(String::new, |value| value.to_string()),
top_p: generation
.top_p
.map_or_else(String::new, |value| value.to_string()),
min_p: generation
.min_p
.map_or_else(String::new, |value| value.to_string()),
seed: generation
.seed
.map_or_else(String::new, |value| value.to_string()),
system_prompt: generation.system_prompt.clone(),
temperature: optional_string(generation.temperature),
top_p: optional_string(generation.top_p),
min_p: optional_string(generation.min_p),
seed: optional_string(generation.seed),
reasoning_mode: generation.reasoning_mode,
cpu_threads: execution
.cpu_threads
.map_or_else(String::new, |value| value.to_string()),
power_percent: execution
.power_percent
.map_or_else(String::new, |value| value.to_string()),
prefill_chunk: execution
.prefill_chunk
.map_or_else(String::new, |value| value.to_string()),
cpu_threads: optional_string(execution.cpu_threads),
power_percent: optional_string(execution.power_percent),
prefill_chunk: optional_string(execution.prefill_chunk),
quality: execution.quality,
warm_weights: execution.warm_weights,
mtp_draft_tokens: speculative.mtp_draft_tokens.to_string(),
mtp_margin: speculative.mtp_margin.to_string(),
glm_mtp: speculative.glm_mtp,
glm_mtp_timing: speculative.glm_mtp_timing,
dspark_confidence_threshold: speculative
.dspark_confidence_threshold
.map_or_else(String::new, |value| value.to_string()),
dspark_confidence_threshold: optional_string(speculative.dspark_confidence_threshold),
dspark_strict: speculative.dspark_strict,
ssd_streaming: runtime.ssd.enabled,
ssd_streaming_cold: runtime.ssd.cold,
ssd_cache: runtime
.ssd
.cache
.map_or_else(String::new, |cache| match cache {
StreamingCacheBudget::Experts(experts) => experts.to_string(),
StreamingCacheBudget::Gib(gib) => format!("{gib}GB"),
}),
ssd_full_layers: runtime
.ssd
.full_layers
.map_or_else(String::new, |value| value.to_string()),
ssd_preload_experts: runtime
.ssd
.preload_experts
.map_or_else(String::new, |value| value.to_string()),
directional_steering_file: runtime.steering.file.unwrap_or_default(),
directional_steering_ffn: runtime
.steering
.ffn_scale
.map_or_else(String::new, |value| value.to_string()),
directional_steering_attn: runtime
.steering
.attention_scale
.map_or_else(String::new, |value| value.to_string()),
simulated_used_memory_gib: runtime
ssd_cache: optional_string(runtime.ssd.cache),
ssd_full_layers: optional_string(runtime.ssd.full_layers),
ssd_preload_experts: optional_string(runtime.ssd.preload_experts),
directional_steering_file: runtime.steering.file.clone().unwrap_or_default(),
directional_steering_ffn: optional_string(runtime.steering.ffn_scale),
directional_steering_attn: optional_string(runtime.steering.attention_scale),
simulated_used_memory_gib: optional_string(
runtime.diagnostics.simulated_used_memory_gib,
),
expert_profile_path: runtime
.diagnostics
.simulated_used_memory_gib
.map_or_else(String::new, |value| value.to_string()),
expert_profile_path: runtime.diagnostics.expert_profile_path.unwrap_or_default(),
kv_budget_gib: runtime
.kv_cache
.budget_gib
.map_or_else(String::new, |value| value.to_string()),
kv_min_tokens: runtime
.kv_cache
.min_tokens
.map_or_else(String::new, |value| value.to_string()),
kv_cold_max_tokens: runtime
.kv_cache
.cold_max_tokens
.map_or_else(String::new, |value| value.to_string()),
kv_continued_interval_tokens: runtime
.kv_cache
.continued_interval_tokens
.map_or_else(String::new, |value| value.to_string()),
})
.expert_profile_path
.clone()
.unwrap_or_default(),
kv_budget_gib: optional_string(runtime.kv_cache.budget_gib),
kv_min_tokens: optional_string(runtime.kv_cache.min_tokens),
kv_cold_max_tokens: optional_string(runtime.kv_cache.cold_max_tokens),
kv_continued_interval_tokens: optional_string(
runtime.kv_cache.continued_interval_tokens,
),
}
}
pub(super) fn generation(&self) -> Result<GenerationPreferences, String> {
@@ -163,49 +119,7 @@ impl PreferenceDraft {
}
pub(super) fn reset(&mut self) {
let defaults = GenerationPreferences::default();
let execution = ExecutionPreferences::default();
let speculative = SpeculativePreferences::default();
let ssd = SsdPreferences::default();
self.model = ModelChoice::default();
self.dspark_enabled = false;
self.idle_timeout_minutes = "10".into();
self.endpoint_port = "4000".into();
self.endpoint_enabled = true;
self.endpoint_cors = false;
self.context_tokens = defaults.context_tokens.to_string();
self.max_generated_tokens = defaults.max_generated_tokens.to_string();
self.system_prompt = defaults.system_prompt;
self.temperature.clear();
self.top_p.clear();
self.min_p.clear();
self.seed.clear();
self.reasoning_mode = defaults.reasoning_mode;
self.cpu_threads.clear();
self.power_percent.clear();
self.prefill_chunk.clear();
self.quality = execution.quality;
self.warm_weights = execution.warm_weights;
self.mtp_draft_tokens = speculative.mtp_draft_tokens.to_string();
self.mtp_margin = speculative.mtp_margin.to_string();
self.glm_mtp = speculative.glm_mtp;
self.glm_mtp_timing = speculative.glm_mtp_timing;
self.dspark_confidence_threshold.clear();
self.dspark_strict = speculative.dspark_strict;
self.ssd_streaming = ssd.enabled;
self.ssd_streaming_cold = ssd.cold;
self.ssd_cache.clear();
self.ssd_full_layers.clear();
self.ssd_preload_experts.clear();
self.directional_steering_file.clear();
self.directional_steering_ffn.clear();
self.directional_steering_attn.clear();
self.simulated_used_memory_gib.clear();
self.expert_profile_path.clear();
self.kv_budget_gib.clear();
self.kv_min_tokens.clear();
self.kv_cold_max_tokens.clear();
self.kv_continued_interval_tokens.clear();
*self = Self::from_saved(&Config::default());
}
pub(super) fn execution(&self) -> Result<ExecutionPreferences, String> {
@@ -349,30 +263,7 @@ pub(super) fn parse_streaming_cache(value: &str) -> Result<Option<StreamingCache
if value.is_empty() {
return Ok(None);
}
if value.len() > 2
&& value
.get(value.len() - 2..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case("gb"))
{
return parse_gib("SSD cache budget", value)
.map(|gib| Some(StreamingCacheBudget::Gib(gib)));
}
if !value.chars().all(|character| character.is_ascii_digit()) {
return Err(
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
.into(),
);
}
value
.parse::<u32>()
.ok()
.filter(|value| *value > 0)
.map(StreamingCacheBudget::Experts)
.map(Some)
.ok_or_else(|| {
"SSD cache budget must be a positive expert count or whole GiB value such as 64GB."
.into()
})
value.parse().map(Some)
}
pub(super) fn parse_optional_gib(name: &str, value: &str) -> Result<Option<u64>, String> {
@@ -404,13 +295,15 @@ fn optional_text(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_owned())
}
fn update_runtime_preferences(
runtime_preferences: &RwLock<AppPreferences>,
preferences: &AppPreferences,
) {
*runtime_preferences
/// An unset preference shows as an empty field.
fn optional_string<T: ToString>(value: Option<T>) -> String {
value.map_or_else(String::new, |value| value.to_string())
}
fn update_runtime_config(runtime_config: &RwLock<Config>, config: &Config) {
*runtime_config
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = preferences.clone();
.unwrap_or_else(|poisoned| poisoned.into_inner()) = config.clone();
}
impl App {
@@ -418,14 +311,9 @@ impl App {
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return;
}
match PreferenceDraft::from_saved(&self.preferences) {
Ok(draft) => {
self.preference_draft = draft;
self.preference_error = None;
self.preferences_open = true;
}
Err(error) => self.error = Some(error),
}
self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None;
self.preferences_open = true;
}
pub(super) fn save_preferences(&mut self) {
@@ -438,18 +326,10 @@ impl App {
self.preference_error = Some("Idle timeout must be a whole number.".into());
return;
};
if !(1..=1440).contains(&idle_timeout_minutes) {
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
return;
}
let Ok(endpoint_port) = self.preference_draft.endpoint_port.trim().parse::<u16>() else {
self.preference_error = Some("Endpoint port must be a whole number.".into());
return;
};
if endpoint_port == 0 {
self.preference_error = Some("Endpoint port must be between 1 and 65535.".into());
return;
}
let generation = match self.preference_draft.generation() {
Ok(generation) => generation,
Err(error) => {
@@ -457,8 +337,6 @@ impl App {
return;
}
};
let model = self.preference_draft.model;
let runtime = match self.preference_draft.runtime() {
Ok(runtime) => runtime,
Err(error) => {
@@ -466,76 +344,72 @@ impl App {
return;
}
};
if let Err(error) = runtime.validate(model) {
let config = Config {
model: self.preference_draft.model,
idle_timeout_minutes,
endpoint: EndpointConfig {
port: i32::from(endpoint_port),
enabled: self.preference_draft.endpoint_enabled,
cors: self.preference_draft.endpoint_cors,
},
generation,
runtime,
interface: self.config.interface.clone(),
};
if let Err(error) = config.validate() {
self.preference_error = Some(error);
return;
}
#[cfg(target_os = "macos")]
let endpoint_changed = self.preferences.endpoint_port != i32::from(endpoint_port)
|| self.preferences.endpoint_enabled != self.preference_draft.endpoint_enabled
|| self.preferences.endpoint_cors != self.preference_draft.endpoint_cors;
let endpoint_changed = self.config.endpoint != config.endpoint;
#[cfg(target_os = "macos")]
if endpoint_changed
&& self.preferences.endpoint_port == i32::from(endpoint_port)
&& self.config.endpoint.port == config.endpoint.port
&& self._endpoint.is_some()
{
self._endpoint = None;
}
let pending_endpoint = if self.preference_draft.endpoint_enabled
&& (endpoint_changed || self._endpoint.is_none())
{
let Some(generation) = &self.generation_service else {
self.preference_error = Some("The model runtime is unavailable.".into());
return;
};
match crate::server::ServerHandle::spawn(
generation.clone(),
Arc::clone(&self.runtime_preferences),
models_path(),
application_support_path().join("kv-cache").join("http"),
endpoint_port,
self.preference_draft.endpoint_cors,
Arc::clone(&self.metrics),
) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
self.preference_error = Some(error);
let pending_endpoint =
if config.endpoint.enabled && (endpoint_changed || self._endpoint.is_none()) {
let Some(generation) = &self.generation_service else {
self.preference_error = Some("The model runtime is unavailable.".into());
return;
};
match crate::server::ServerHandle::spawn(
generation.clone(),
Arc::clone(&self.runtime_config),
models_path(),
application_support_path().join("kv-cache").join("http"),
endpoint_port,
config.endpoint.cors,
Arc::clone(&self.metrics),
) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
self.preference_error = Some(error);
return;
}
}
}
} else {
None
};
let Some(database) = &mut self.database else {
} else {
None
};
if let Err(error) = config.save(&config_path()) {
self.preference_error = Some(error);
return;
};
match database.update_preferences(
model.id(),
idle_timeout_minutes,
i32::from(endpoint_port),
self.preference_draft.endpoint_enabled,
self.preference_draft.endpoint_cors,
&generation,
&runtime,
) {
Ok(preferences) => {
self.preferences = preferences;
#[cfg(target_os = "macos")]
update_runtime_preferences(&self.runtime_preferences, &self.preferences);
#[cfg(target_os = "macos")]
if endpoint_changed {
self._endpoint = pending_endpoint;
} else if let Some(endpoint) = pending_endpoint {
self._endpoint = Some(endpoint);
}
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
.expect("the saved model was selected from the supported catalog");
self.preferences_open = false;
self.preference_error = None;
self.error = None;
}
Err(error) => self.preference_error = Some(error),
}
self.config = config;
#[cfg(target_os = "macos")]
update_runtime_config(&self.runtime_config, &self.config);
#[cfg(target_os = "macos")]
if endpoint_changed {
self._endpoint = pending_endpoint;
} else if let Some(endpoint) = pending_endpoint {
self._endpoint = Some(endpoint);
}
self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preferences_open = false;
self.preference_error = None;
self.error = None;
}
}
@@ -544,26 +418,30 @@ mod tests {
use super::*;
#[test]
fn runtime_preferences_update_after_lock_poisoning() {
let runtime = Arc::new(RwLock::new(AppPreferences::default()));
fn runtime_config_updates_after_lock_poisoning() {
let runtime = Arc::new(RwLock::new(Config::default()));
let poisoned = Arc::clone(&runtime);
let _ = std::thread::spawn(move || {
let _guard = poisoned.write().unwrap();
panic!("poison preference lock");
panic!("poison configuration lock");
})
.join();
let preferences = AppPreferences {
endpoint_port: 4567,
..AppPreferences::default()
let config = Config {
endpoint: EndpointConfig {
port: 4567,
..EndpointConfig::default()
},
..Config::default()
};
update_runtime_preferences(&runtime, &preferences);
update_runtime_config(&runtime, &config);
assert_eq!(
runtime
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.endpoint_port,
.endpoint
.port,
4567
);
}