From 5d441038bc8c949f88eb938e8df022fa2a3b4339 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sun, 26 Jul 2026 09:50:39 +0200 Subject: [PATCH] Move preferences from the database to a YAML config file Co-Authored-By: Claude Opus 5 --- Cargo.lock | 26 + Cargo.toml | 1 + README.md | 8 +- .../20260726160000_drop_preferences/down.sql | 1 + .../20260726160000_drop_preferences/up.sql | 1 + src/app.rs | 137 ++--- src/app/generation.rs | 59 +-- src/app/preferences.rs | 352 ++++-------- src/app/projects.rs | 12 +- src/app/view.rs | 10 +- src/app/view/chat.rs | 16 +- src/config.rs | 199 +++++++ src/database.rs | 501 +----------------- src/main.rs | 1 + src/model.rs | 6 +- src/schema.rs | 51 +- src/server.rs | 40 +- src/server/request.rs | 13 +- src/settings.rs | 101 +++- 19 files changed, 571 insertions(+), 964 deletions(-) create mode 100644 migrations/20260726160000_drop_preferences/down.sql create mode 100644 migrations/20260726160000_drop_preferences/up.sql create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index a008d2a..ebc1328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1152,6 +1152,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "serde_norway", "sha2", "ureq", ] @@ -3957,6 +3958,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4047,6 +4054,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -4867,6 +4887,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml-norway" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 8eb660d..f12f5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ png = "0.17.16" rfd = "0.15.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["preserve_order", "raw_value"] } +serde_norway = "0.9.42" sha2 = "0.11.0" ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } diff --git a/README.md b/README.md index fc333a5..af42b8b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ notices are retained in [`native/metal/LICENSE`](native/metal/LICENSE) and The current milestone provides a Codex-inspired project/session layout. A native macOS folder picker selects each workspace, then the app asks for its display -name. Projects, sessions, and model preferences are persisted through Diesel in -SQLite. Open Preferences with `Command-,` to configure model, generation, -runtime, local endpoint, and idle-unload settings. The separate Model Manager +name. Projects and sessions are persisted through Diesel in SQLite, while +settings live in a YAML file beside it that lists only the values you changed +and can be edited by hand. Open Preferences with `Command-,` to configure model, +generation, runtime, local endpoint, and idle-unload settings. The separate Model Manager (`Shift-Command-M`) lists local main and DSpark artifacts, their on-disk sizes and state, and lets you download, resume, validate, or delete them. Rust-native background work @@ -57,6 +58,7 @@ State is stored at: ```text ~/Library/Application Support/DS4Server.rfc1437.de/data.sqlite3 +~/Library/Application Support/DS4Server.rfc1437.de/config.yaml ~/Library/Application Support/DS4Server.rfc1437.de/kv-cache/ ``` diff --git a/migrations/20260726160000_drop_preferences/down.sql b/migrations/20260726160000_drop_preferences/down.sql new file mode 100644 index 0000000..17e361e --- /dev/null +++ b/migrations/20260726160000_drop_preferences/down.sql @@ -0,0 +1 @@ +-- Preferences live in config.yaml; the table is not recreated. diff --git a/migrations/20260726160000_drop_preferences/up.sql b/migrations/20260726160000_drop_preferences/up.sql new file mode 100644 index 0000000..5dab8c4 --- /dev/null +++ b/migrations/20260726160000_drop_preferences/up.sql @@ -0,0 +1 @@ +DROP TABLE preferences; diff --git a/src/app.rs b/src/app.rs index 6c8edd5..fa31056 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,8 @@ use preferences::PreferenceDraft; #[cfg(test)] use preferences::{parse_optional_gib, parse_streaming_cache}; -use crate::database::{AppPreferences, Database, ProjectWithSessions, SessionState, StoredMessage}; +use crate::config::{Config, EndpointConfig}; +use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage}; #[cfg(target_os = "macos")] use crate::engine::ChatTurn; use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot}; @@ -20,9 +21,9 @@ use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, M #[cfg(target_os = "macos")] use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService}; use crate::settings::{ - DEFAULT_KV_BUDGET_GIB, DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, - KvCachePreferences, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, - SteeringPreferences, StreamingCacheBudget, + DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, KvCachePreferences, + ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences, + StreamingCacheBudget, }; use iced::widget::{markdown, scrollable, text_input}; use iced::{Size, Subscription, Task, keyboard, mouse, window}; @@ -53,7 +54,7 @@ pub(crate) struct App { pub(super) native_edit_commands: crate::native_edit::EditCommandQueue, database: Option, projects: Vec, - preferences: AppPreferences, + config: Config, preference_draft: PreferenceDraft, preferences_open: bool, preference_error: Option, @@ -99,7 +100,7 @@ pub(crate) struct App { #[cfg(target_os = "macos")] active_titling: Option, #[cfg(target_os = "macos")] - runtime_preferences: Arc>, + runtime_config: Arc>, #[cfg(target_os = "macos")] _endpoint: Option, error: Option, @@ -217,22 +218,24 @@ pub(crate) enum Message { impl App { pub(crate) fn load(main_window: window::Id) -> Self { + let config = match Config::load(&config_path()) { + Ok(config) => config, + Err(error) => return Self::failed(error, main_window), + }; let path = application_support_path().join("data.sqlite3"); match Database::open(&path) { - Ok(mut database) => match (database.load_projects(), database.load_preferences()) { - (Ok(projects), Ok(preferences)) => { - let preference_draft = match PreferenceDraft::from_saved(&preferences) { - Ok(draft) => draft, - Err(error) => return Self::failed(error, main_window), - }; + Ok(mut database) => match database.load_projects() { + Ok(projects) => { + let preference_draft = PreferenceDraft::from_saved(&config); // Before the counters are taken, so they measure what is // actually reachable. sweep_orphan_checkpoints(&kv_cache_path(), &projects); #[cfg(target_os = "macos")] crate::engine::sweep_transient_cache(&transient_cache_path()); - let context_limit = preferences.context_tokens.max(0) as u32; + let context_limit = config.generation.context_tokens.max(0) as u32; // Reopen on the project we left, with a fresh draft chat. - let last_project = preferences + let last_project = config + .interface .last_project_id .filter(|id| projects.iter().any(|item| item.project.id == *id)); let drafts = last_project @@ -243,8 +246,8 @@ impl App { Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); #[cfg(target_os = "macos")] - let (runtime_preferences, generation_service, endpoint, service_error) = - spawn_services(&preferences, Arc::clone(&metrics)); + let (runtime_config, generation_service, endpoint, service_error) = + spawn_services(&config, Arc::clone(&metrics)); Self { main_window, model_manager_window: None, @@ -255,7 +258,7 @@ impl App { native_edit_commands: crate::native_edit::command_queue(), database: Some(database), projects, - preferences, + config, preference_draft, preferences_open: false, preference_error: None, @@ -294,7 +297,7 @@ impl App { #[cfg(target_os = "macos")] active_titling: None, #[cfg(target_os = "macos")] - runtime_preferences, + runtime_config, #[cfg(target_os = "macos")] _endpoint: endpoint, error: { @@ -309,22 +312,27 @@ impl App { }, } } - (Err(error), _) | (_, Err(error)) => Self::failed(error, main_window), + Err(error) => Self::failed( + format!("Could not open the project database: {error}"), + main_window, + ), }, - Err(error) => Self::failed(error, main_window), + Err(error) => Self::failed( + format!("Could not open the project database: {error}"), + main_window, + ), } } fn failed(error: String, main_window: window::Id) -> Self { - let preferences = AppPreferences::default(); - let preference_draft = PreferenceDraft::from_saved(&preferences) - .expect("default preferences must use a supported model"); - let context_limit = preferences.context_tokens.max(0) as u32; + let config = Config::default(); + let preference_draft = PreferenceDraft::from_saved(&config); + let context_limit = config.generation.context_tokens.max(0) as u32; let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); #[cfg(target_os = "macos")] - let (runtime_preferences, generation_service, endpoint, service_error) = - spawn_services(&preferences, Arc::clone(&metrics)); + let (runtime_config, generation_service, endpoint, service_error) = + spawn_services(&config, Arc::clone(&metrics)); #[cfg(target_os = "macos")] let startup_error = service_error; #[cfg(not(target_os = "macos"))] @@ -339,7 +347,7 @@ impl App { native_edit_commands: crate::native_edit::command_queue(), database: None, projects: Vec::new(), - preferences, + config, preference_draft, preferences_open: false, preference_error: None, @@ -378,14 +386,12 @@ impl App { #[cfg(target_os = "macos")] active_titling: None, #[cfg(target_os = "macos")] - runtime_preferences, + runtime_config, #[cfg(target_os = "macos")] _endpoint: endpoint, error: Some(match startup_error { - Some(service_error) => { - format!("Could not open the project database: {error}. {service_error}") - } - None => format!("Could not open the project database: {error}"), + Some(service_error) => format!("{error} {service_error}"), + None => error, }), } } @@ -453,29 +459,19 @@ impl App { Message::StartSidebarDrag => self.sidebar_drag = true, Message::DragSidebar(x) => { if self.sidebar_drag { - self.preferences.sidebar_width = + self.config.interface.sidebar_width = (x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); } } Message::EndSidebarDrag => { if self.sidebar_drag { self.sidebar_drag = false; - if let Some(database) = self.database.as_mut() - && let Err(error) = - database.set_sidebar_width(self.preferences.sidebar_width) - { - self.error = Some(error); - } + self.store_config(); } } Message::ToggleSidebar => { - let collapsed = !self.preferences.sidebar_collapsed; - self.preferences.sidebar_collapsed = collapsed; - if let Some(database) = self.database.as_mut() - && let Err(error) = database.set_sidebar_collapsed(collapsed) - { - self.error = Some(error); - } + self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed; + self.store_config(); } Message::MetricsTick => self.sample_metrics(), Message::PreferenceModelChanged(model) => { @@ -829,9 +825,9 @@ impl App { let _ = fs::remove_file(session_checkpoint_path(session_id)); } self.drafts.remove(&project_id); - if self.preferences.last_project_id == Some(project_id) { - self.preferences.last_project_id = None; - let _ = database.set_last_project(None); + if self.config.interface.last_project_id == Some(project_id) { + self.config.interface.last_project_id = None; + let _ = self.config.save(&config_path()); } if self.selected_project == Some(project_id) { self.selected_project = None; @@ -957,7 +953,7 @@ impl App { self.context_limit = if limit > 0 { limit as u32 } else { - self.preferences.context_tokens.max(0) as u32 + self.config.generation.context_tokens.max(0) as u32 }; self.tokens_per_second = tokens_per_second; self.error = None; @@ -1128,6 +1124,13 @@ impl App { self.finish_cache_change(); } + /// Writes the configuration file, surfacing a failure in the error banner. + fn store_config(&mut self) { + if let Err(error) = self.config.save(&config_path()) { + self.error = Some(error); + } + } + fn finish_cache_change(&mut self) { self.metrics.rescan_cache(&kv_cache_path()); self.metrics_snapshot = self.metrics.snapshot(); @@ -1135,12 +1138,7 @@ impl App { } fn scan_kv_cache(&mut self) { - let budget = self - .preferences - .runtime() - .map_or(DEFAULT_KV_BUDGET_GIB * GIB, |runtime| { - runtime.kv_cache.settings().budget_bytes - }); + let budget = self.config.runtime.kv_cache.settings().budget_bytes; self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget); self.last_cache_scan = Instant::now(); } @@ -1148,34 +1146,34 @@ impl App { #[cfg(target_os = "macos")] fn spawn_services( - preferences: &AppPreferences, + config: &Config, metrics: Arc, ) -> ( - Arc>, + Arc>, Option, Option, Option, ) { - let runtime_preferences = Arc::new(RwLock::new(preferences.clone())); + let runtime_config = Arc::new(RwLock::new(config.clone())); let generation = match GenerationService::spawn(Arc::clone(&metrics)) { Ok(generation) => generation, - Err(error) => return (runtime_preferences, None, None, Some(error)), + Err(error) => return (runtime_config, None, None, Some(error)), }; - let endpoint = preferences.endpoint_enabled.then(|| { + let endpoint = config.endpoint.enabled.then(|| { crate::server::ServerHandle::spawn( generation.clone(), - Arc::clone(&runtime_preferences), + Arc::clone(&runtime_config), models_path(), transient_cache_path(), - u16::try_from(preferences.endpoint_port).unwrap_or(4000), - preferences.endpoint_cors, + u16::try_from(config.endpoint.port).unwrap_or(4000), + config.endpoint.cors, metrics, ) }); match endpoint { - Some(Ok(endpoint)) => (runtime_preferences, Some(generation), Some(endpoint), None), - Some(Err(error)) => (runtime_preferences, Some(generation), None, Some(error)), - None => (runtime_preferences, Some(generation), None, None), + Some(Ok(endpoint)) => (runtime_config, Some(generation), Some(endpoint), None), + Some(Err(error)) => (runtime_config, Some(generation), None, Some(error)), + None => (runtime_config, Some(generation), None, None), } } @@ -1219,6 +1217,11 @@ fn models_path() -> PathBuf { application_support_path().join("models") } +/// The settings file, beside the project database. +fn config_path() -> PathBuf { + application_support_path().join("config.yaml") +} + pub(super) fn chat_scroll_id() -> scrollable::Id { scrollable::Id::new("chat-transcript") } diff --git a/src/app/generation.rs b/src/app/generation.rs index 5d715fe..e6b2f82 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -79,18 +79,13 @@ impl App { if prompt.is_empty() { return; } - let model = match ModelChoice::from_id(&self.preferences.selected_model) { - Some(model) => model, - None => { - self.error = Some("The selected model is not supported.".into()); - return; - } - }; - let effective = self.preferences.generation().and_then(|generation| { - self.preferences.runtime().and_then(|runtime| { - crate::settings::effective_settings(model, &generation, &runtime, &models_path()) - }) - }); + let model = self.config.model; + let effective = crate::settings::effective_settings( + model, + &self.config.generation, + &self.config.runtime, + &models_path(), + ); let mut effective = match effective { Ok(settings) => settings, Err(error) => { @@ -158,7 +153,7 @@ impl App { } }; let idle_timeout = - Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); + Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); self.active_generation = match service.generate( effective.engine, effective.turn, @@ -255,8 +250,7 @@ impl App { Ok(GenerationEvent::Finished(result)) => { match result { Ok(_) => { - let model = ModelChoice::from_id(&self.preferences.selected_model) - .unwrap_or_default(); + let model = self.config.model; let content = self .conversation .last() @@ -356,7 +350,7 @@ impl App { .find(|project| project.project.id == project_id) .map(|project| PathBuf::from(&project.project.path)) .ok_or_else(|| "The active project is unavailable.".to_owned())?; - let tools = crate::agent::Tools::new(&root, self.preferences.context_tokens)?; + let tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?; self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); } let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); @@ -369,12 +363,13 @@ impl App { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; - let model = ModelChoice::from_id(&self.preferences.selected_model) - .ok_or_else(|| "The selected model is not supported.".to_owned())?; - let generation = self.preferences.generation()?; - let runtime = self.preferences.runtime()?; - let mut effective = - crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; + let model = self.config.model; + let mut effective = crate::settings::effective_settings( + model, + &self.config.generation, + &self.config.runtime, + &models_path(), + )?; effective.turn.system_prompt = crate::agent::system_prompt(model, &effective.turn.system_prompt); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; @@ -400,8 +395,7 @@ impl App { let mut assistant = ChatMessage::from(saved.1); assistant.reasoning_open = assistant_reasoning; self.conversation.push(assistant); - let idle_timeout = - Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); + let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); self.active_generation = Some( self.generation_service .as_ref() @@ -428,13 +422,13 @@ impl App { if self.active_titling.is_some() { return Err("A title is already being generated.".into()); } - let model = ModelChoice::from_id(&self.preferences.selected_model) - .ok_or_else(|| "The selected model is not supported.".to_owned())?; - let mut effective = self.preferences.generation().and_then(|generation| { - self.preferences.runtime().and_then(|runtime| { - crate::settings::effective_settings(model, &generation, &runtime, &models_path()) - }) - })?; + let model = self.config.model; + let mut effective = crate::settings::effective_settings( + model, + &self.config.generation, + &self.config.runtime, + &models_path(), + )?; let database = self .database .as_mut() @@ -475,8 +469,7 @@ impl App { .generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())?; - let idle_timeout = - Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); + let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); match service.generate( effective.engine, effective.turn, diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 5319ba1..73a7b31 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -44,104 +44,60 @@ pub(super) struct PreferenceDraft { } impl PreferenceDraft { - pub(super) fn from_saved(preferences: &AppPreferences) -> Result { - 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 { @@ -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 { @@ -349,30 +263,7 @@ pub(super) fn parse_streaming_cache(value: &str) -> Result 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::() - .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, String> { @@ -404,13 +295,15 @@ fn optional_text(value: &str) -> Option { (!value.is_empty()).then(|| value.to_owned()) } -fn update_runtime_preferences( - runtime_preferences: &RwLock, - preferences: &AppPreferences, -) { - *runtime_preferences +/// An unset preference shows as an empty field. +fn optional_string(value: Option) -> String { + value.map_or_else(String::new, |value| value.to_string()) +} + +fn update_runtime_config(runtime_config: &RwLock, 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::() 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 ); } diff --git a/src/app/projects.rs b/src/app/projects.rs index 3315bfb..46da271 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -75,7 +75,7 @@ impl App { self.conversation.clear(); self.composer.clear(); self.context_used = 0; - self.context_limit = self.preferences.context_tokens.max(0) as u32; + self.context_limit = self.config.generation.context_tokens.max(0) as u32; self.tokens_per_second = None; self.error = None; } @@ -113,15 +113,11 @@ impl App { /// Selects a project and stores it as the one to reopen on the next launch. pub(super) fn remember_project(&mut self, project_id: i32) { self.selected_project = Some(project_id); - if self.preferences.last_project_id == Some(project_id) { + if self.config.interface.last_project_id == Some(project_id) { return; } - self.preferences.last_project_id = Some(project_id); - if let Some(database) = &mut self.database - && let Err(error) = database.set_last_project(Some(project_id)) - { - self.error = Some(error); - } + self.config.interface.last_project_id = Some(project_id); + self.store_config(); } /// True when the sidebar row for this project's draft is the active chat. diff --git a/src/app/view.rs b/src/app/view.rs index dec94ee..867b787 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -64,7 +64,7 @@ impl App { fn main_view(&self) -> Element<'_, Message> { let mut body = row![].width(Length::Fill).height(Length::Fill); - if !self.preferences.sidebar_collapsed { + if !self.config.interface.sidebar_collapsed { body = body.push(self.sidebar()); body = body.push( mouse_area( @@ -272,7 +272,8 @@ impl App { .spacing(10), ) .width( - self.preferences + self.config + .interface .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32, ) @@ -327,10 +328,11 @@ impl App { .style(button::text); // Center the tabs over the detail area, not the whole window, so the // sidebar's width (plus its divider) shifts them along. - let detail_offset = if self.preferences.sidebar_collapsed { + let detail_offset = if self.config.interface.sidebar_collapsed { 0.0 } else { - self.preferences + self.config + .interface .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) as f32 + 5.0 diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 90870fb..7bd83fa 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -115,11 +115,10 @@ impl App { } else if active && message.reasoning.is_none() { body = body.push(text("Loading model…").size(14)); } - if !message.user - && !message.tool - && let Some(model) = ModelChoice::from_id(&self.preferences.selected_model) - { - for summary in crate::agent::tool_summaries(model, &message.content) { + if !message.user && !message.tool { + for summary in + crate::agent::tool_summaries(self.config.model, &message.content) + { body = body.push(text(summary).size(13).color(muted_text())); } } @@ -185,12 +184,7 @@ impl App { .color(muted_text()), Space::with_width(Length::Fill), icon(ICON_MODEL, 16), - text( - ModelChoice::from_id(&self.preferences.selected_model) - .unwrap_or_default() - .to_string(), - ) - .size(12), + text(self.config.model.to_string()).size(12), action, ] .spacing(6) diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..27d23f4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,199 @@ +use serde::{Deserialize, Serialize}; +use serde_norway::{Mapping, Value}; +use std::fs; +use std::path::Path; + +use crate::model::ModelChoice; +use crate::settings::{GenerationPreferences, RuntimePreferences}; + +/// Settings the application persists beside its database, as a YAML file users +/// and agents can edit by hand. Only values that differ from the defaults are +/// written; anything missing falls back to the default. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub model: ModelChoice, + pub idle_timeout_minutes: i32, + pub endpoint: EndpointConfig, + pub generation: GenerationPreferences, + pub runtime: RuntimePreferences, + pub interface: InterfaceConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + model: ModelChoice::default(), + idle_timeout_minutes: 10, + endpoint: EndpointConfig::default(), + generation: GenerationPreferences::default(), + runtime: RuntimePreferences::default(), + interface: InterfaceConfig::default(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct EndpointConfig { + pub port: i32, + pub enabled: bool, + pub cors: bool, +} + +impl Default for EndpointConfig { + fn default() -> Self { + Self { + port: 4000, + enabled: true, + cors: false, + } + } +} + +/// Window state the application restores on the next launch. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct InterfaceConfig { + pub sidebar_collapsed: bool, + pub sidebar_width: i32, + /// Project the app reopens on. Cleared when that project goes away. + pub last_project_id: Option, +} + +impl Default for InterfaceConfig { + fn default() -> Self { + Self { + sidebar_collapsed: false, + sidebar_width: 276, + last_project_id: None, + } + } +} + +impl Config { + /// A missing file is the default configuration; a malformed one is an error, + /// so a typo never silently resets everything. + pub fn load(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(error) => return Err(format!("Could not read {}: {error}", path.display())), + }; + let config: Self = serde_norway::from_str(&text) + .map_err(|error| format!("Could not read {}: {error}", path.display()))?; + config.validate()?; + Ok(config) + } + + pub fn save(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let value = serde_norway::to_value(self).map_err(|error| error.to_string())?; + let defaults = + serde_norway::to_value(Self::default()).map_err(|error| error.to_string())?; + let value = without_defaults(value, &defaults) + .unwrap_or_else(|| Value::Mapping(Mapping::default())); + let text = serde_norway::to_string(&value).map_err(|error| error.to_string())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + fs::write(path, text) + .map_err(|error| format!("Could not write {}: {error}", path.display())) + } + + pub fn validate(&self) -> Result<(), String> { + if !(1..=1440).contains(&self.idle_timeout_minutes) { + return Err("Idle timeout must be between 1 and 1440 minutes.".into()); + } + if !(1..=65_535).contains(&self.endpoint.port) { + return Err("Endpoint port must be between 1 and 65535.".into()); + } + self.generation.validate()?; + self.runtime.validate(self.model) + } +} + +/// Drops every value that still matches the default, so the file lists only what +/// the user actually set. Mappings that end up empty disappear with their key. +fn without_defaults(value: Value, defaults: &Value) -> Option { + if let (Value::Mapping(entries), Value::Mapping(defaults)) = (&value, defaults) { + let kept = entries + .iter() + .filter_map(|(key, entry)| { + let default = defaults.get(key)?; + Some((key.clone(), without_defaults(entry.clone(), default)?)) + }) + .collect::(); + return (!kept.is_empty()).then_some(Value::Mapping(kept)); + } + (&value != defaults).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{ReasoningMode, SsdPreferences, StreamingCacheBudget}; + + #[test] + fn defaults_write_an_empty_file_and_reload_unchanged() { + let directory = std::env::temp_dir().join(format!("ds4-config-{}", std::process::id())); + let path = directory.join("config.yaml"); + Config::default().save(&path).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap().trim(), "{}"); + assert_eq!(Config::load(&path).unwrap(), Config::default()); + fs::remove_dir_all(&directory).unwrap(); + } + + #[test] + fn only_changed_values_are_written_and_read_back() { + let directory = std::env::temp_dir().join(format!("ds4-config-set-{}", std::process::id())); + let path = directory.join("config.yaml"); + let config = Config { + model: ModelChoice::Glm52, + generation: GenerationPreferences { + context_tokens: 65_536, + reasoning_mode: ReasoningMode::Direct, + ..GenerationPreferences::default() + }, + runtime: RuntimePreferences { + ssd: SsdPreferences { + enabled: true, + cache: Some(StreamingCacheBudget::Gib(64)), + ..SsdPreferences::default() + }, + ..RuntimePreferences::default() + }, + ..Config::default() + }; + config.save(&path).unwrap(); + + let text = fs::read_to_string(&path).unwrap(); + assert_eq!( + text, + "model: glm-5.2\n\ + generation:\n context_tokens: 65536\n reasoning_mode: none\n\ + runtime:\n ssd:\n enabled: true\n cache: 64GB\n" + ); + assert_eq!(Config::load(&path).unwrap(), config); + fs::remove_dir_all(&directory).unwrap(); + } + + #[test] + fn invalid_files_are_reported_instead_of_reset() { + let directory = std::env::temp_dir().join(format!("ds4-config-bad-{}", std::process::id())); + let path = directory.join("config.yaml"); + fs::create_dir_all(&directory).unwrap(); + + fs::write(&path, "idle_timeout_minutes: 0\n").unwrap(); + assert!(Config::load(&path).is_err()); + fs::write(&path, "nonsense: 1\n").unwrap(); + assert!(Config::load(&path).is_err()); + fs::remove_dir_all(&directory).unwrap(); + + // A missing file is simply the defaults. + assert_eq!(Config::load(&path).unwrap(), Config::default()); + } +} diff --git a/src/database.rs b/src/database.rs index 6bac420..74128af 100644 --- a/src/database.rs +++ b/src/database.rs @@ -4,288 +4,10 @@ use std::collections::HashMap; use std::fs; use std::path::Path; -use crate::schema::{messages, preferences, projects, sessions}; -use crate::settings::{ - DiagnosticPreferences, ExecutionPreferences, GenerationPreferences, KvCachePreferences, - ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences, - StreamingCacheBudget, -}; +use crate::schema::{messages, projects, sessions}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -#[derive(Clone, Debug, Identifiable, Queryable, Selectable)] -#[diesel(table_name = preferences)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct AppPreferences { - pub id: i32, - pub selected_model: String, - pub dspark_enabled: bool, - pub idle_timeout_minutes: i32, - pub context_tokens: i32, - pub max_generated_tokens: i32, - pub system_prompt: String, - pub temperature: Option, - pub top_p: Option, - pub min_p: Option, - pub seed: Option, - pub reasoning_mode: String, - pub cpu_threads: Option, - pub power_percent: Option, - pub prefill_chunk: Option, - pub quality: bool, - pub warm_weights: bool, - pub mtp_draft_tokens: i32, - pub mtp_margin: f32, - pub glm_mtp: bool, - pub glm_mtp_timing: bool, - pub dspark_confidence_threshold: Option, - pub dspark_strict: bool, - pub ssd_streaming: bool, - pub ssd_streaming_cold: bool, - pub ssd_cache_experts: Option, - pub ssd_cache_gib: Option, - pub ssd_full_layers: Option, - pub ssd_preload_experts: Option, - pub directional_steering_file: Option, - pub directional_steering_ffn: Option, - pub directional_steering_attn: Option, - pub simulated_used_memory_gib: Option, - pub expert_profile_path: Option, - pub endpoint_port: i32, - pub endpoint_enabled: bool, - pub endpoint_cors: bool, - pub sidebar_collapsed: bool, - /// Project the app reopens on. Cleared when that project goes away. - pub last_project_id: Option, - pub sidebar_width: i32, - pub kv_budget_gib: Option, - pub kv_min_tokens: Option, - pub kv_cold_max_tokens: Option, - pub kv_continued_interval_tokens: Option, -} - -impl Default for AppPreferences { - fn default() -> Self { - Self { - id: 1, - selected_model: "deepseek-v4-flash".into(), - dspark_enabled: false, - idle_timeout_minutes: 10, - context_tokens: 32_768, - max_generated_tokens: 50_000, - system_prompt: "You are a helpful assistant".into(), - temperature: None, - top_p: None, - min_p: None, - seed: None, - reasoning_mode: "high".into(), - cpu_threads: None, - power_percent: None, - prefill_chunk: None, - quality: false, - warm_weights: false, - mtp_draft_tokens: 1, - mtp_margin: 3.0, - glm_mtp: false, - glm_mtp_timing: false, - dspark_confidence_threshold: None, - dspark_strict: false, - ssd_streaming: false, - ssd_streaming_cold: false, - ssd_cache_experts: None, - ssd_cache_gib: None, - ssd_full_layers: None, - ssd_preload_experts: None, - directional_steering_file: None, - directional_steering_ffn: None, - directional_steering_attn: None, - simulated_used_memory_gib: None, - expert_profile_path: None, - endpoint_port: 4000, - endpoint_enabled: true, - endpoint_cors: false, - sidebar_collapsed: false, - last_project_id: None, - sidebar_width: 276, - kv_budget_gib: None, - kv_min_tokens: None, - kv_cold_max_tokens: None, - kv_continued_interval_tokens: None, - } - } -} - -impl AppPreferences { - pub(crate) fn generation(&self) -> Result { - let preferences = GenerationPreferences { - context_tokens: self.context_tokens, - max_generated_tokens: self.max_generated_tokens, - system_prompt: self.system_prompt.clone(), - temperature: self.temperature, - top_p: self.top_p, - min_p: self.min_p, - seed: self - .seed - .as_deref() - .map(str::parse) - .transpose() - .map_err(|_| "Saved seed is not a valid positive whole number.".to_owned())?, - reasoning_mode: ReasoningMode::from_id(&self.reasoning_mode) - .ok_or_else(|| format!("Unsupported reasoning mode: {}", self.reasoning_mode))?, - }; - preferences.validate()?; - Ok(preferences) - } - - pub(crate) fn execution(&self) -> Result { - Ok(ExecutionPreferences { - cpu_threads: self - .cpu_threads - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved CPU helper threads is invalid.".to_owned())?, - power_percent: self - .power_percent - .map(u8::try_from) - .transpose() - .map_err(|_| "Saved GPU power is invalid.".to_owned())?, - prefill_chunk: self - .prefill_chunk - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved prefill chunk is invalid.".to_owned())?, - quality: self.quality, - warm_weights: self.warm_weights, - }) - } - - pub(crate) fn speculative(&self) -> SpeculativePreferences { - SpeculativePreferences { - mtp_draft_tokens: self.mtp_draft_tokens, - mtp_margin: self.mtp_margin, - glm_mtp: self.glm_mtp, - glm_mtp_timing: self.glm_mtp_timing, - dspark_enabled: self.dspark_enabled, - dspark_confidence_threshold: self.dspark_confidence_threshold, - dspark_strict: self.dspark_strict, - } - } - - pub(crate) fn runtime(&self) -> Result { - let cache = match (self.ssd_cache_experts, self.ssd_cache_gib) { - (None, None) => None, - (Some(experts), None) => Some(StreamingCacheBudget::Experts( - u32::try_from(experts).map_err(|_| "Saved SSD expert count is invalid.")?, - )), - (None, Some(gib)) => Some(StreamingCacheBudget::Gib( - u64::try_from(gib).map_err(|_| "Saved SSD cache budget is invalid.")?, - )), - (Some(_), Some(_)) => { - return Err("Saved SSD cache budget has both count and GiB values.".into()); - } - }; - Ok(RuntimePreferences { - execution: self.execution()?, - speculative: self.speculative(), - ssd: SsdPreferences { - enabled: self.ssd_streaming, - cold: self.ssd_streaming_cold, - cache, - full_layers: self - .ssd_full_layers - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved SSD full-layer count is invalid.")?, - preload_experts: self - .ssd_preload_experts - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved SSD preload count is invalid.")?, - }, - steering: SteeringPreferences { - file: self.directional_steering_file.clone(), - ffn_scale: self.directional_steering_ffn, - attention_scale: self.directional_steering_attn, - }, - diagnostics: DiagnosticPreferences { - simulated_used_memory_gib: self - .simulated_used_memory_gib - .map(u64::try_from) - .transpose() - .map_err(|_| "Saved simulated memory is invalid.")?, - expert_profile_path: self.expert_profile_path.clone(), - }, - kv_cache: KvCachePreferences { - budget_gib: self - .kv_budget_gib - .map(u64::try_from) - .transpose() - .map_err(|_| "Saved KV cache budget is invalid.")?, - min_tokens: self - .kv_min_tokens - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved KV cache minimum tokens is invalid.")?, - cold_max_tokens: self - .kv_cold_max_tokens - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved KV cache cold maximum is invalid.")?, - continued_interval_tokens: self - .kv_continued_interval_tokens - .map(u32::try_from) - .transpose() - .map_err(|_| "Saved KV cache continued interval is invalid.")?, - }, - }) - } -} - -#[derive(AsChangeset)] -#[diesel(table_name = preferences)] -struct PreferenceChanges<'a> { - selected_model: &'a str, - dspark_enabled: bool, - idle_timeout_minutes: i32, - context_tokens: i32, - max_generated_tokens: i32, - system_prompt: &'a str, - temperature: Option, - top_p: Option, - min_p: Option, - seed: Option<&'a str>, - reasoning_mode: &'a str, - cpu_threads: Option, - power_percent: Option, - prefill_chunk: Option, - quality: bool, - warm_weights: bool, - mtp_draft_tokens: i32, - mtp_margin: f32, - glm_mtp: bool, - glm_mtp_timing: bool, - dspark_confidence_threshold: Option, - dspark_strict: bool, - ssd_streaming: bool, - ssd_streaming_cold: bool, - ssd_cache_experts: Option, - ssd_cache_gib: Option, - ssd_full_layers: Option, - ssd_preload_experts: Option, - directional_steering_file: Option<&'a str>, - directional_steering_ffn: Option, - directional_steering_attn: Option, - simulated_used_memory_gib: Option, - expert_profile_path: Option<&'a str>, - endpoint_port: i32, - endpoint_enabled: bool, - endpoint_cors: bool, - kv_budget_gib: Option, - kv_min_tokens: Option, - kv_cold_max_tokens: Option, - kv_continued_interval_tokens: Option, -} - #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] #[diesel(table_name = projects)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -466,126 +188,6 @@ impl Database { .collect()) } - pub fn load_preferences(&mut self) -> Result { - preferences::table - .find(1) - .select(AppPreferences::as_select()) - .first(&mut self.connection) - .map_err(|error| error.to_string()) - } - - pub fn set_sidebar_collapsed(&mut self, collapsed: bool) -> Result<(), String> { - diesel::update(preferences::table.find(1)) - .set(preferences::sidebar_collapsed.eq(collapsed)) - .execute(&mut self.connection) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - - pub fn set_sidebar_width(&mut self, width: i32) -> Result<(), String> { - diesel::update(preferences::table.find(1)) - .set(preferences::sidebar_width.eq(width)) - .execute(&mut self.connection) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - - pub fn set_last_project(&mut self, project_id: Option) -> Result<(), String> { - diesel::update(preferences::table.find(1)) - .set(preferences::last_project_id.eq(project_id)) - .execute(&mut self.connection) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - - #[allow(clippy::too_many_arguments)] - pub fn update_preferences( - &mut self, - selected_model: &str, - idle_timeout_minutes: i32, - endpoint_port: i32, - endpoint_enabled: bool, - endpoint_cors: bool, - generation: &GenerationPreferences, - runtime: &RuntimePreferences, - ) -> Result { - generation.validate()?; - let model = crate::model::ModelChoice::from_id(selected_model) - .ok_or_else(|| format!("Unsupported model: {selected_model}"))?; - runtime.validate(model)?; - if !(1..=65_535).contains(&endpoint_port) { - return Err("Endpoint port must be between 1 and 65535.".into()); - } - let execution = &runtime.execution; - let speculative = &runtime.speculative; - let (ssd_cache_experts, ssd_cache_gib) = match runtime.ssd.cache { - None => (None, None), - Some(StreamingCacheBudget::Experts(experts)) => (Some(i64::from(experts)), None), - Some(StreamingCacheBudget::Gib(gib)) => ( - None, - Some(i64::try_from(gib).map_err(|_| "SSD cache budget is too large.")?), - ), - }; - let seed = generation.seed.map(|seed| seed.to_string()); - diesel::update(preferences::table.find(1)) - .set(PreferenceChanges { - selected_model, - dspark_enabled: speculative.dspark_enabled, - idle_timeout_minutes, - context_tokens: generation.context_tokens, - max_generated_tokens: generation.max_generated_tokens, - system_prompt: &generation.system_prompt, - temperature: generation.temperature, - top_p: generation.top_p, - min_p: generation.min_p, - seed: seed.as_deref(), - reasoning_mode: generation.reasoning_mode.id(), - cpu_threads: execution.cpu_threads.map(|value| value as i32), - power_percent: execution.power_percent.map(i32::from), - prefill_chunk: execution.prefill_chunk.map(|value| value as i32), - quality: execution.quality, - warm_weights: execution.warm_weights, - mtp_draft_tokens: speculative.mtp_draft_tokens, - mtp_margin: speculative.mtp_margin, - glm_mtp: speculative.glm_mtp, - glm_mtp_timing: speculative.glm_mtp_timing, - dspark_confidence_threshold: speculative.dspark_confidence_threshold, - dspark_strict: speculative.dspark_strict, - ssd_streaming: runtime.ssd.enabled, - ssd_streaming_cold: runtime.ssd.cold, - ssd_cache_experts, - ssd_cache_gib, - ssd_full_layers: runtime.ssd.full_layers.map(|value| value as i32), - ssd_preload_experts: runtime.ssd.preload_experts.map(|value| value as i32), - directional_steering_file: runtime.steering.file.as_deref(), - directional_steering_ffn: runtime.steering.ffn_scale, - directional_steering_attn: runtime.steering.attention_scale, - simulated_used_memory_gib: runtime - .diagnostics - .simulated_used_memory_gib - .map(|value| value as i64), - expert_profile_path: runtime.diagnostics.expert_profile_path.as_deref(), - endpoint_port, - endpoint_enabled, - endpoint_cors, - kv_budget_gib: runtime - .kv_cache - .budget_gib - .map(i64::try_from) - .transpose() - .map_err(|_| "KV cache budget is too large.")?, - kv_min_tokens: runtime.kv_cache.min_tokens.map(|value| value as i32), - kv_cold_max_tokens: runtime.kv_cache.cold_max_tokens.map(|value| value as i32), - kv_continued_interval_tokens: runtime - .kv_cache - .continued_interval_tokens - .map(|value| value as i32), - }) - .returning(AppPreferences::as_returning()) - .get_result(&mut self.connection) - .map_err(|error| error.to_string()) - } - pub fn create_project(&mut self, name: &str, path: &str) -> Result { diesel::insert_into(projects::table) .values(NewProject { name, path }) @@ -805,94 +407,6 @@ mod tests { let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3")); let mut database = Database::open(&path).unwrap(); - let preferences = database.load_preferences().unwrap(); - assert_eq!(preferences.selected_model, "deepseek-v4-flash"); - assert!(!preferences.dspark_enabled); - assert_eq!(preferences.idle_timeout_minutes, 10); - assert_eq!(preferences.endpoint_port, 4000); - assert!(preferences.endpoint_enabled); - assert!(!preferences.endpoint_cors); - let generation = GenerationPreferences::default(); - let runtime = RuntimePreferences::default(); - assert!( - database - .update_preferences( - "glm-5.2", - 30, - 4000, - true, - false, - &generation, - &RuntimePreferences { - speculative: SpeculativePreferences { - dspark_enabled: true, - ..runtime.speculative.clone() - }, - ..runtime.clone() - } - ) - .is_err() - ); - assert!( - database - .update_preferences( - "deepseek-v4-flash", - 0, - 4000, - true, - false, - &generation, - &runtime, - ) - .is_err() - ); - let generation = GenerationPreferences { - top_p: Some(0.7), - seed: Some(u64::MAX), - reasoning_mode: ReasoningMode::Max, - ..generation - }; - let execution = ExecutionPreferences { - cpu_threads: Some(8), - quality: true, - ..runtime.execution - }; - let speculative = SpeculativePreferences { - glm_mtp: true, - glm_mtp_timing: true, - mtp_draft_tokens: 4, - mtp_margin: 2.5, - ..runtime.speculative - }; - let runtime = RuntimePreferences { - execution, - speculative, - ssd: SsdPreferences { - enabled: true, - cache: Some(StreamingCacheBudget::Gib(64)), - full_layers: Some(0), - preload_experts: Some(32), - ..SsdPreferences::default() - }, - diagnostics: DiagnosticPreferences { - simulated_used_memory_gib: Some(8), - expert_profile_path: Some("/tmp/experts.json".into()), - }, - kv_cache: KvCachePreferences { - budget_gib: Some(16), - min_tokens: Some(256), - cold_max_tokens: Some(0), - continued_interval_tokens: Some(4_096), - }, - ..RuntimePreferences::default() - }; - database - .update_preferences("glm-5.2", 30, 4567, false, true, &generation, &runtime) - .unwrap(); - let preferences = database.load_preferences().unwrap(); - assert!(!preferences.endpoint_enabled); - assert!(preferences.endpoint_cors); - let project = database.create_project("DS4", "/tmp/ds4").unwrap(); let first = database .create_session(project.id, "First session") @@ -937,9 +451,6 @@ mod tests { let loaded = database.load_projects().unwrap(); assert_eq!(loaded[0].sessions[0].id, ordinary); - database.set_sidebar_collapsed(true).unwrap(); - database.set_last_project(Some(project.id)).unwrap(); - database.set_sidebar_width(320).unwrap(); assert!(!loaded[0].project.collapsed); database.set_project_collapsed(project.id, true).unwrap(); assert!(database.load_projects().unwrap()[0].project.collapsed); @@ -949,15 +460,7 @@ mod tests { drop(database); let mut reopened = Database::open(&path).unwrap(); - let preferences = reopened.load_preferences().unwrap(); - assert!(preferences.sidebar_collapsed); - assert_eq!(preferences.last_project_id, Some(project.id)); - assert_eq!(preferences.sidebar_width, 320); - assert_eq!(preferences.selected_model, "glm-5.2"); - assert_eq!(preferences.idle_timeout_minutes, 30); - assert_eq!(preferences.endpoint_port, 4567); - assert_eq!(preferences.generation().unwrap(), generation); - assert_eq!(preferences.runtime().unwrap(), runtime); + assert!(reopened.load_projects().unwrap().is_empty()); drop(reopened); fs::remove_file(path).unwrap(); } diff --git a/src/main.rs b/src/main.rs index 5d16ff4..f4db3d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod agent; mod app; +mod config; mod database; mod engine; mod metrics; diff --git a/src/model.rs b/src/model.rs index 7f0c698..ec7c750 100644 --- a/src/model.rs +++ b/src/model.rs @@ -4,6 +4,7 @@ pub(crate) use transfer::{ delete_managed_artifact, download_managed_artifact, validate_managed_artifact, }; +use serde::{Deserialize, Serialize}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -56,11 +57,14 @@ const GLM: Artifact = Artifact { support: Some(false), }; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub(crate) enum ModelChoice { #[default] + #[serde(rename = "deepseek-v4-flash")] DeepSeekV4Flash, + #[serde(rename = "deepseek-v4-pro")] DeepSeekV4Pro, + #[serde(rename = "glm-5.2")] Glm52, } diff --git a/src/schema.rs b/src/schema.rs index c3f1623..44f018d 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -19,55 +19,6 @@ diesel::table! { } } -diesel::table! { - preferences (id) { - id -> Integer, - selected_model -> Text, - dspark_enabled -> Bool, - idle_timeout_minutes -> Integer, - context_tokens -> Integer, - max_generated_tokens -> Integer, - system_prompt -> Text, - temperature -> Nullable, - top_p -> Nullable, - min_p -> Nullable, - seed -> Nullable, - reasoning_mode -> Text, - cpu_threads -> Nullable, - power_percent -> Nullable, - prefill_chunk -> Nullable, - quality -> Bool, - warm_weights -> Bool, - mtp_draft_tokens -> Integer, - mtp_margin -> Float, - glm_mtp -> Bool, - glm_mtp_timing -> Bool, - dspark_confidence_threshold -> Nullable, - dspark_strict -> Bool, - ssd_streaming -> Bool, - ssd_streaming_cold -> Bool, - ssd_cache_experts -> Nullable, - ssd_cache_gib -> Nullable, - ssd_full_layers -> Nullable, - ssd_preload_experts -> Nullable, - directional_steering_file -> Nullable, - directional_steering_ffn -> Nullable, - directional_steering_attn -> Nullable, - simulated_used_memory_gib -> Nullable, - expert_profile_path -> Nullable, - endpoint_port -> Integer, - endpoint_enabled -> Bool, - endpoint_cors -> Bool, - sidebar_collapsed -> Bool, - last_project_id -> Nullable, - sidebar_width -> Integer, - kv_budget_gib -> Nullable, - kv_min_tokens -> Nullable, - kv_cold_max_tokens -> Nullable, - kv_continued_interval_tokens -> Nullable, - } -} - diesel::table! { sessions (id) { id -> Integer, @@ -82,4 +33,4 @@ diesel::table! { diesel::joinable!(sessions -> projects (project_id)); diesel::joinable!(messages -> sessions (session_id)); -diesel::allow_tables_to_appear_in_same_query!(messages, preferences, projects, sessions); +diesel::allow_tables_to_appear_in_same_query!(messages, projects, sessions); diff --git a/src/server.rs b/src/server.rs index 5ca2938..b935efd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -20,7 +20,7 @@ use tools::{ #[cfg(test)] use tools::{canonical_tools, validate_tool_results}; -use crate::database::AppPreferences; +use crate::config::Config; use crate::engine::ChatTurn; use crate::metrics::Metrics; use crate::model::{self, ModelChoice}; @@ -70,7 +70,7 @@ pub(crate) struct ServerHandle { struct State { generation: GenerationService, - preferences: Arc>, + config: Arc>, models_path: PathBuf, cache_path: PathBuf, sequence: AtomicU64, @@ -212,7 +212,7 @@ struct RawOpenAiTool<'a> { impl ServerHandle { pub(crate) fn spawn( generation: GenerationService, - preferences: Arc>, + config: Arc>, models_path: PathBuf, cache_path: PathBuf, port: u16, @@ -230,7 +230,7 @@ impl ServerHandle { let tool_memory = read_tool_memory(&cache_path); let state = Arc::new(State { generation, - preferences, + config, models_path, cache_path, sequence: AtomicU64::new(0), @@ -417,12 +417,12 @@ fn handle(mut stream: TcpStream, state: &State) { let installed = installed_endpoint_models(&state.models_path); let model = model_alias(id).filter(|model| installed.contains(model)); if let Some(model) = model { - let (context, tokens) = state - .preferences - .read() - .map_or((32_768, 32_768), |preferences| { - (preferences.context_tokens, preferences.max_generated_tokens) - }); + let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| { + ( + config.generation.context_tokens, + config.generation.max_generated_tokens, + ) + }); send_json_with_cors( &mut stream, 200, @@ -549,12 +549,12 @@ fn installed_endpoint_models(models_path: &std::path::Path) -> Vec } fn models_json(state: &State) -> Value { - let (context, tokens) = state - .preferences - .read() - .map_or((32_768, 32_768), |preferences| { - (preferences.context_tokens, preferences.max_generated_tokens) - }); + let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| { + ( + config.generation.context_tokens, + config.generation.max_generated_tokens, + ) + }); json!({ "object": "list", "data": installed_endpoint_models(&state.models_path) @@ -735,7 +735,7 @@ mod tests { let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let state = State { generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), - preferences: Arc::new(RwLock::new(AppPreferences::default())), + config: Arc::new(RwLock::new(Config::default())), models_path: PathBuf::new(), cache_path: PathBuf::new(), sequence: AtomicU64::new(0), @@ -773,7 +773,7 @@ mod tests { let metrics = Arc::new(Metrics::new(&directory)); let state = State { generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), - preferences: Arc::new(RwLock::new(AppPreferences::default())), + config: Arc::new(RwLock::new(Config::default())), models_path: PathBuf::new(), cache_path: directory.clone(), sequence: AtomicU64::new(0), @@ -794,7 +794,7 @@ mod tests { let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let state = State { generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), - preferences: Arc::new(RwLock::new(AppPreferences::default())), + config: Arc::new(RwLock::new(Config::default())), models_path: PathBuf::new(), cache_path: PathBuf::new(), sequence: AtomicU64::new(0), @@ -847,7 +847,7 @@ mod tests { let metrics = Arc::new(Metrics::new(PathBuf::new().as_path())); let state = State { generation: GenerationService::spawn(Arc::clone(&metrics)).unwrap(), - preferences: Arc::new(RwLock::new(AppPreferences::default())), + config: Arc::new(RwLock::new(Config::default())), models_path: PathBuf::new(), cache_path: PathBuf::new(), sequence: AtomicU64::new(0), diff --git a/src/server/request.rs b/src/server/request.rs index 4a14746..b114cbd 100644 --- a/src/server/request.rs +++ b/src/server/request.rs @@ -484,13 +484,13 @@ pub(super) fn parse_chat_request( return Err((400, "missing messages".into())); } let requested_id = request.model.as_deref().unwrap_or_default(); - let preferences = state - .preferences + let config = state + .config .read() .map_err(|_| (500, "preferences are unavailable".to_owned()))? .clone(); let model = if requested_id.is_empty() { - ModelChoice::from_id(&preferences.selected_model) + Some(config.model) } else { model_alias(requested_id) } @@ -499,7 +499,7 @@ pub(super) fn parse_chat_request( return Err((400, format!("model is not installed and verified: {model}"))); } - let mut generation = preferences.generation().map_err(|error| (500, error))?; + let mut generation = config.generation.clone(); generation.system_prompt.clear(); generation.max_generated_tokens = request .max_completion_tokens @@ -525,8 +525,7 @@ pub(super) fn parse_chat_request( protocol, )?; generation.system_prompt = system; - let runtime = preferences.runtime().map_err(|error| (500, error))?; - let mut effective = effective_settings(model, &generation, &runtime, &state.models_path) + let mut effective = effective_settings(model, &generation, &config.runtime, &state.models_path) .map_err(|error| (400, error))?; effective.turn.top_k = request.top_k.unwrap_or(0); if effective.turn.top_k < 0 { @@ -549,7 +548,7 @@ pub(super) fn parse_chat_request( messages, turn: effective.turn, engine: effective.engine, - idle_timeout: Duration::from_secs(preferences.idle_timeout_minutes.max(1) as u64 * 60), + idle_timeout: Duration::from_secs(config.idle_timeout_minutes.max(1) as u64 * 60), stream: request.stream, include_usage: request .stream_options diff --git a/src/settings.rs b/src/settings.rs index 4e395a5..f4daec6 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,4 +1,5 @@ use crate::model::{self, EngineArtifacts, ModelChoice}; +use serde::{Deserialize, Serialize}; use std::fmt; use std::path::Path; @@ -17,7 +18,8 @@ const DEFAULT_KV_MIN_TOKENS: u32 = 512; const DEFAULT_KV_COLD_MAX_TOKENS: u32 = 30_000; const DEFAULT_KV_CONTINUED_INTERVAL_TOKENS: u32 = 10_000; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct SpeculativePreferences { pub(crate) mtp_draft_tokens: i32, pub(crate) mtp_margin: f32, @@ -94,13 +96,70 @@ pub(crate) struct EngineSpeculativeSettings { pub(crate) dspark_strict: bool, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// An expert count, or a whole GiB budget. Written as `4` or `64GB`, the same +/// spelling the preferences window accepts. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(into = "String", try_from = "String")] pub(crate) enum StreamingCacheBudget { Experts(u32), Gib(u64), } -#[derive(Clone, Debug, Default, Eq, PartialEq)] +impl fmt::Display for StreamingCacheBudget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Experts(experts) => write!(formatter, "{experts}"), + Self::Gib(gib) => write!(formatter, "{gib}GB"), + } + } +} + +impl From for String { + fn from(budget: StreamingCacheBudget) -> Self { + budget.to_string() + } +} + +impl TryFrom for StreamingCacheBudget { + type Error = String; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl std::str::FromStr for StreamingCacheBudget { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + let gib = value + .get(value.len().saturating_sub(2)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("gb")); + let digits = if gib { + &value[..value.len() - 2] + } else { + value + }; + let number = (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| digits.parse::().ok()) + .flatten() + .filter(|number| *number > 0); + match number { + Some(number) if gib && number <= u64::MAX / GIB => Ok(Self::Gib(number)), + Some(number) if !gib && number <= u64::from(u32::MAX) => { + Ok(Self::Experts(number as u32)) + } + _ => Err( + "SSD cache budget must be a positive expert count or whole GiB value such as 64GB." + .into(), + ), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct SsdPreferences { pub(crate) enabled: bool, pub(crate) cold: bool, @@ -166,7 +225,8 @@ pub(crate) struct EngineSsdSettings { /// Disk KV cache management, mirroring ds4's `--kv-disk-space-mb` budget and /// its store-side gates. Unset values keep the DS4 defaults. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct KvCachePreferences { pub(crate) budget_gib: Option, pub(crate) min_tokens: Option, @@ -219,7 +279,8 @@ impl KvCacheSettings { } } -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct SteeringPreferences { pub(crate) file: Option, pub(crate) ffn_scale: Option, @@ -270,7 +331,8 @@ pub(crate) struct EngineSteeringSettings { pub(crate) attention_scale: f32, } -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct DiagnosticPreferences { pub(crate) simulated_used_memory_gib: Option, pub(crate) expert_profile_path: Option, @@ -298,7 +360,8 @@ pub(crate) struct EngineDiagnosticSettings { pub(crate) expert_profile_path: Option, } -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct RuntimePreferences { pub(crate) execution: ExecutionPreferences, pub(crate) speculative: SpeculativePreferences, @@ -354,7 +417,8 @@ pub(crate) struct EngineSettings { pub(crate) diagnostics: EngineDiagnosticSettings, } -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct ExecutionPreferences { pub(crate) cpu_threads: Option, pub(crate) power_percent: Option, @@ -420,28 +484,16 @@ pub(crate) struct EngineExecutionSettings { pub(crate) warm_weights: bool, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] pub(crate) enum ReasoningMode { + #[serde(rename = "none")] Direct, #[default] High, Max, } -impl ReasoningMode { - pub(crate) fn id(self) -> &'static str { - match self { - Self::Direct => "none", - Self::High => "high", - Self::Max => "max", - } - } - - pub(crate) fn from_id(id: &str) -> Option { - REASONING_MODES.into_iter().find(|mode| mode.id() == id) - } -} - impl fmt::Display for ReasoningMode { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { @@ -452,7 +504,8 @@ impl fmt::Display for ReasoningMode { } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] pub(crate) struct GenerationPreferences { pub(crate) context_tokens: i32, pub(crate) max_generated_tokens: i32,