diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0bd32e7..277bf3e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -19,9 +19,10 @@ The composer status row shows the current project and, for Git repositories, the current local branch. An unsaved draft can be moved with the project menu; saved chats keep their original project. Use the branch menu to switch local branches when that project's chats are idle. The thinking menu is preselected -to the model's current mode: **Thinking** is the DS4 default, while **Direct** -answers without hidden reasoning. **Think Max** is available when the context -window is at least 393216 tokens, matching DS4's long-context requirement. +to the active model's default mode and loads that model-and-thinking profile: +**Thinking** is the DS4 default, while **Direct** answers without hidden +reasoning. **Think Max** is available when its profile uses at least 393216 +context tokens, matching DS4's long-context requirement. The **Git** pane shows added, changed, and deleted files from the current worktree, including separate staged and worktree markers. Select files to stage, @@ -66,6 +67,9 @@ Open **View > Model Manager** (`⇧⌘M`) to download, resume, verify, or remove supported model artifacts. Preferences choose the active model and control generation, context, speculative decoding, Metal execution, SSD expert streaming, steering, checkpoint storage, diagnostics, and the local endpoint. +Generation values are stored per model and thinking mode. Acceleration and SSD +values are stored per model, while the Prompt section's system prompt is shared +by every profile. Model files are large. Verification checks the complete artifact before it is used. Removing a model never removes projects or chat history. diff --git a/src/a2ui_validation.rs b/src/a2ui_validation.rs index 7dffec0..f7d171b 100644 --- a/src/a2ui_validation.rs +++ b/src/a2ui_validation.rs @@ -100,7 +100,7 @@ pub(crate) fn run(args: impl Iterator) -> Result<(), String> { .timeout_recv_body(Some(Duration::from_secs(30))) .build() .into(); - let mut system = crate::agent::system_prompt(model, &config.generation.system_prompt, false); + let mut system = crate::agent::system_prompt(model, &config.system_prompt, false); system.push_str("\n\n"); system.push_str(crate::a2ui::SYSTEM_PROMPT); let metadata = Store::default().client_metadata(); diff --git a/src/app.rs b/src/app.rs index 8f4b456..d441025 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use preferences::{parse_optional_gib, parse_streaming_cache}; use crate::config::{ Config, DevBrainConfig, EndpointConfig, GitConfig, GitDiffAlgorithm, GitDiffLayout, - GitDiffWhitespace, PermissionMode, + GitDiffWhitespace, ModelPreferences, PermissionMode, }; use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage}; #[cfg(any(target_os = "macos", test))] @@ -37,7 +37,7 @@ use crate::settings::{ use iced::widget::{markdown, scrollable, text_editor}; use iced::{Size, Subscription, Task, keyboard, mouse, window}; use rfd::AsyncFileDialog; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -261,6 +261,7 @@ pub(super) enum PreferenceSection { Endpoint, DevBrain, Git, + Prompt, Generation, Execution, Acceleration, @@ -269,11 +270,12 @@ pub(super) enum PreferenceSection { } impl PreferenceSection { - const ALL: [Self; 9] = [ + const ALL: [Self; 10] = [ Self::Model, Self::Endpoint, Self::DevBrain, Self::Git, + Self::Prompt, Self::Generation, Self::Execution, Self::Acceleration, @@ -287,6 +289,7 @@ impl PreferenceSection { Self::Endpoint => "preferences-endpoint", Self::DevBrain => "preferences-dev-brain", Self::Git => "preferences-git", + Self::Prompt => "preferences-prompt", Self::Generation => "preferences-generation", Self::Execution => "preferences-execution", Self::Acceleration => "preferences-acceleration", @@ -301,6 +304,7 @@ impl PreferenceSection { Self::Endpoint => "Local endpoint", Self::DevBrain => "Dev Brain", Self::Git => "Git diffs", + Self::Prompt => "Prompt", Self::Generation => "Generation", Self::Execution => "Execution", Self::Acceleration => "Acceleration & memory", @@ -350,6 +354,10 @@ pub(crate) enum Message { FocusNext, FocusPrevious, PreferenceModelChanged(ModelChoice), + PreferenceDefaultReasoningChanged(ReasoningMode), + PreferenceGenerationModelChanged(ModelChoice), + PreferenceGenerationReasoningChanged(ReasoningMode), + PreferenceAccelerationModelChanged(ModelChoice), PreferencePermissionModeChanged(PermissionMode), PreferenceLegacyMtpChanged(bool), PreferenceDsparkChanged(bool), @@ -379,7 +387,6 @@ pub(crate) enum Message { PreferenceTopPChanged(String), PreferenceMinPChanged(String), PreferenceSeedChanged(String), - PreferenceReasoningChanged(ReasoningMode), PreferenceCpuThreadsChanged(String), PreferencePowerChanged(String), PreferencePrefillChunkChanged(String), @@ -505,7 +512,7 @@ impl App { sweep_orphan_checkpoints(&kv_cache_path(), &projects); #[cfg(target_os = "macos")] crate::engine::sweep_transient_cache(&transient_cache_path()); - let context_limit = config.generation.context_tokens.max(0) as u32; + let context_limit = config.active_generation().context_tokens.max(0) as u32; let default_permission_mode = config.default_permission_mode; // Reopen on the project we left, with a fresh draft chat. let last_project = config @@ -658,7 +665,7 @@ impl App { fn failed(error: String, main_window: window::Id) -> Self { let config = Config::default(); let preference_draft = PreferenceDraft::from_saved(&config); - let context_limit = config.generation.context_tokens.max(0) as u32; + let context_limit = config.active_generation().context_tokens.max(0) as u32; let default_permission_mode = config.default_permission_mode; let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); @@ -811,7 +818,7 @@ impl App { context_used: std::mem::take(&mut self.context_used), context_limit: std::mem::replace( &mut self.context_limit, - self.config.generation.context_tokens.max(0) as u32, + self.config.active_generation().context_tokens.max(0) as u32, ), tokens_per_second: self.tokens_per_second.take(), detail_tab: std::mem::take(&mut self.detail_tab), @@ -1545,8 +1552,7 @@ impl App { Some("Stop all active generations before changing thinking mode.".into()); } else if !self .config - .generation - .supported_reasoning_modes() + .supported_reasoning_modes(self.config.model) .contains(&mode) { self.error = Some( @@ -1554,11 +1560,23 @@ impl App { ); } else { let mut config = self.config.clone(); - config.generation.reasoning_mode = mode; + config + .model_profiles + .entry(config.model) + .or_default() + .reasoning_mode = mode; match config.save(&config_path()) { Ok(()) => { self.config = config; - self.preference_draft.reasoning_mode = mode; + self.preference_draft.default_reasoning_mode = mode; + self.preference_draft + .load_generation(self.config.model, mode); + self.context_limit = + self.config.active_generation().context_tokens.max(0) as u32; + #[cfg(target_os = "macos")] + { + self.agent_tools = None; + } self.error = None; #[cfg(target_os = "macos")] preferences::update_runtime_config(&self.runtime_config, &self.config); @@ -1812,7 +1830,7 @@ impl App { self.context_limit = if limit > 0 { limit as u32 } else { - self.config.generation.context_tokens.max(0) as u32 + self.config.active_generation().context_tokens.max(0) as u32 }; self.tokens_per_second = tokens_per_second; self.permission_mode = permission_mode; diff --git a/src/app/generation.rs b/src/app/generation.rs index dfc7332..ee353f8 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -524,12 +524,10 @@ impl App { return; } let model = self.config.model; - let effective = crate::settings::effective_settings( - model, - &self.config.generation, - &self.config.runtime, - &models_path(), - ); + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path()); let mut effective = match effective { Ok(settings) => settings, Err(error) => { @@ -728,8 +726,8 @@ impl App { if self.config.a2ui_enabled { reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned()); } - if !self.config.generation.system_prompt.trim().is_empty() { - reminders.push(self.config.generation.system_prompt.clone()); + if !self.config.system_prompt.trim().is_empty() { + reminders.push(self.config.system_prompt.clone()); } if let Some(agents) = self.session_agents_prompt() { reminders.push(agents.to_owned()); @@ -1239,7 +1237,8 @@ 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 mut tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?; + let mut tools = + crate::agent::Tools::new(&root, self.config.active_generation().context_tokens)?; if self.config.dev_brain.enabled { let projects = self .projects @@ -1254,10 +1253,12 @@ impl App { let approval_mode = match self.permission_mode { PermissionMode::Heuristic => crate::agent::ShellApprovalMode::Heuristic, PermissionMode::Ai => { + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(self.config.model); let effective = crate::settings::effective_settings( self.config.model, - &self.config.generation, - &self.config.runtime, + &generation, + &runtime, &models_path(), )?; let service = self @@ -1290,12 +1291,10 @@ impl App { .ok_or_else(|| "The active session is unavailable.".to_owned())?; self.skip_compaction_once = false; let model = self.config.model; - let mut effective = crate::settings::effective_settings( - model, - &self.config.generation, - &self.config.runtime, - &models_path(), - )?; + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let mut effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, @@ -1395,12 +1394,10 @@ impl App { ); } let model = self.config.model; - let mut effective = crate::settings::effective_settings( - model, - &self.config.generation, - &self.config.runtime, - &models_path(), - )?; + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let mut effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, @@ -1548,12 +1545,10 @@ impl App { reason: &str, ) -> Result<(), String> { let model = self.config.model; - let mut effective = crate::settings::effective_settings( - model, - &self.config.generation, - &self.config.runtime, - &models_path(), - )?; + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let mut effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, @@ -1788,12 +1783,10 @@ impl App { return Err("A title is already being generated.".into()); } let model = self.config.model; - let mut effective = crate::settings::effective_settings( - model, - &self.config.generation, - &self.config.runtime, - &models_path(), - )?; + let generation = self.config.active_generation(); + let runtime = self.config.runtime_for(model); + let mut effective = + crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; let database = self .database .as_mut() diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 8137be9..45074ab 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -4,6 +4,12 @@ use std::sync::RwLock; #[derive(Clone)] pub(super) struct PreferenceDraft { pub(super) model: ModelChoice, + pub(super) default_reasoning_mode: ReasoningMode, + pub(super) generation_model: ModelChoice, + pub(super) generation_reasoning_mode: ReasoningMode, + pub(super) acceleration_model: ModelChoice, + generation_profiles: BTreeMap>, + model_profiles: BTreeMap, pub(super) default_permission_mode: PermissionMode, pub(super) legacy_mtp_enabled: bool, pub(super) dspark_enabled: bool, @@ -28,7 +34,6 @@ pub(super) struct PreferenceDraft { pub(super) top_p: String, pub(super) min_p: String, pub(super) seed: String, - pub(super) reasoning_mode: ReasoningMode, pub(super) cpu_threads: String, pub(super) power_percent: String, pub(super) prefill_chunk: String, @@ -58,12 +63,19 @@ pub(super) struct PreferenceDraft { impl PreferenceDraft { pub(super) fn from_saved(config: &Config) -> Self { - let generation = &config.generation; - let runtime = &config.runtime; + let default_reasoning_mode = config.reasoning_mode(config.model); + let generation = config.generation_for(config.model, default_reasoning_mode); + let runtime = config.runtime_for(config.model); let execution = &runtime.execution; let speculative = &runtime.speculative; Self { model: config.model, + default_reasoning_mode, + generation_model: config.model, + generation_reasoning_mode: default_reasoning_mode, + acceleration_model: config.model, + generation_profiles: config.generation_profiles.clone(), + model_profiles: config.model_profiles.clone(), default_permission_mode: config.default_permission_mode, legacy_mtp_enabled: speculative.legacy_mtp_enabled, dspark_enabled: speculative.dspark_enabled, @@ -83,12 +95,11 @@ impl PreferenceDraft { git_ignore_blank_lines: config.git.ignore_blank_lines, context_tokens: generation.context_tokens.to_string(), max_generated_tokens: generation.max_generated_tokens.to_string(), - system_prompt: text_editor::Content::with_text(&generation.system_prompt), + system_prompt: text_editor::Content::with_text(&config.system_prompt), 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: optional_string(execution.cpu_threads), power_percent: optional_string(execution.power_percent), prefill_chunk: optional_string(execution.prefill_chunk), @@ -137,7 +148,7 @@ impl PreferenceDraft { top_p: parse_optional_f32("Top-p", &self.top_p)?, min_p: parse_optional_f32("Min-p", &self.min_p)?, seed: parse_optional_u64("Seed", &self.seed)?, - reasoning_mode: self.reasoning_mode, + reasoning_mode: self.generation_reasoning_mode, }; preferences.validate()?; Ok(preferences) @@ -159,6 +170,39 @@ impl PreferenceDraft { *self = Self::from_saved(&Config::default()); } + fn store_generation(&mut self) -> Result<(), String> { + let generation = self.generation()?; + self.generation_profiles + .entry(self.generation_model) + .or_default() + .insert(self.generation_reasoning_mode, generation); + Ok(()) + } + + pub(super) fn load_generation(&mut self, model: ModelChoice, mode: ReasoningMode) { + let mut generation = self + .generation_profiles + .get(&model) + .and_then(|profiles| profiles.get(&mode)) + .cloned() + .unwrap_or_default(); + generation.reasoning_mode = mode; + self.generation_model = model; + self.generation_reasoning_mode = mode; + self.context_tokens = generation.context_tokens.to_string(); + self.max_generated_tokens = generation.max_generated_tokens.to_string(); + self.temperature = optional_string(generation.temperature); + self.top_p = optional_string(generation.top_p); + self.min_p = optional_string(generation.min_p); + self.seed = optional_string(generation.seed); + } + + fn select_generation(&mut self, model: ModelChoice, mode: ReasoningMode) -> Result<(), String> { + self.store_generation()?; + self.load_generation(model, mode); + Ok(()) + } + pub(super) fn execution(&self) -> Result { Ok(ExecutionPreferences { cpu_threads: parse_optional_u32("CPU helper threads", &self.cpu_threads)?, @@ -185,20 +229,92 @@ impl PreferenceDraft { }) } + fn acceleration(&self) -> Result<(SpeculativePreferences, SsdPreferences), String> { + let speculative = self.speculative()?; + let ssd = SsdPreferences { + enabled: self.ssd_streaming, + cold: self.ssd_streaming_cold, + cache: parse_streaming_cache(&self.ssd_cache)?, + full_layers: parse_optional_u32("SSD full-layer count", &self.ssd_full_layers)?, + preload_experts: parse_optional_u32("SSD preload experts", &self.ssd_preload_experts)?, + }; + speculative.validate(self.acceleration_model)?; + ssd.validate(self.acceleration_model)?; + Ok((speculative, ssd)) + } + + fn store_acceleration(&mut self) -> Result<(), String> { + let (speculative, ssd) = self.acceleration()?; + let profile = self + .model_profiles + .entry(self.acceleration_model) + .or_default(); + profile.speculative = speculative; + profile.ssd = ssd; + Ok(()) + } + + fn load_acceleration(&mut self, model: ModelChoice) { + let profile = self.model_profiles.get(&model).cloned().unwrap_or_default(); + let speculative = profile.speculative; + let ssd = profile.ssd; + self.acceleration_model = model; + self.legacy_mtp_enabled = speculative.legacy_mtp_enabled; + self.dspark_enabled = speculative.dspark_enabled; + 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 = optional_string(speculative.dspark_confidence_threshold); + self.dspark_strict = speculative.dspark_strict; + self.ssd_streaming = ssd.enabled; + self.ssd_streaming_cold = ssd.cold; + self.ssd_cache = optional_string(ssd.cache); + self.ssd_full_layers = optional_string(ssd.full_layers); + self.ssd_preload_experts = optional_string(ssd.preload_experts); + } + + fn select_acceleration(&mut self, model: ModelChoice) -> Result<(), String> { + self.store_acceleration()?; + self.load_acceleration(model); + Ok(()) + } + + pub(super) fn effective_for( + &self, + model: ModelChoice, + mode: ReasoningMode, + ) -> Result { + let mut draft = self.clone(); + draft.store_generation()?; + draft.store_acceleration()?; + let mut generation = draft + .generation_profiles + .get(&model) + .and_then(|profiles| profiles.get(&mode)) + .cloned() + .unwrap_or_default(); + generation.system_prompt = draft.system_prompt.text(); + generation.reasoning_mode = mode; + let mut runtime = draft.runtime()?; + if let Some(profile) = draft.model_profiles.get(&model) { + runtime.speculative = profile.speculative.clone(); + runtime.ssd = profile.ssd.clone(); + } + crate::settings::effective_settings(model, &generation, &runtime, &models_path()) + } + + pub(super) fn reasoning_mode_for(&self, model: ModelChoice) -> ReasoningMode { + self.model_profiles + .get(&model) + .map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) + } + pub(super) fn runtime(&self) -> Result { Ok(RuntimePreferences { execution: self.execution()?, - speculative: self.speculative()?, - ssd: SsdPreferences { - enabled: self.ssd_streaming, - cold: self.ssd_streaming_cold, - cache: parse_streaming_cache(&self.ssd_cache)?, - full_layers: parse_optional_u32("SSD full-layer count", &self.ssd_full_layers)?, - preload_experts: parse_optional_u32( - "SSD preload experts", - &self.ssd_preload_experts, - )?, - }, + speculative: SpeculativePreferences::default(), + ssd: SsdPreferences::default(), steering: SteeringPreferences { file: optional_text(&self.directional_steering_file), ffn_scale: parse_optional_f32( @@ -386,13 +502,14 @@ impl App { self.preference_error = Some("Endpoint port must be a whole number.".into()); return; }; - let generation = match self.preference_draft.generation() { - Ok(generation) => generation, - Err(error) => { - self.preference_error = Some(error); - return; - } - }; + if let Err(error) = self.preference_draft.store_generation() { + self.preference_error = Some(error); + return; + } + if let Err(error) = self.preference_draft.store_acceleration() { + self.preference_error = Some(error); + return; + } let runtime = match self.preference_draft.runtime() { Ok(runtime) => runtime, Err(error) => { @@ -407,6 +524,11 @@ impl App { return; } }; + self.preference_draft + .model_profiles + .entry(self.preference_draft.model) + .or_default() + .reasoning_mode = self.preference_draft.default_reasoning_mode; let config = Config { model: self.preference_draft.model, default_permission_mode: self.preference_draft.default_permission_mode, @@ -421,7 +543,9 @@ impl App { enabled: self.preference_draft.dev_brain_enabled, vault_path: optional_text(&self.preference_draft.dev_brain_vault_path), }, - generation, + system_prompt: self.preference_draft.system_prompt.text(), + generation_profiles: self.preference_draft.generation_profiles.clone(), + model_profiles: self.preference_draft.model_profiles.clone(), runtime, git, interface: self.config.interface.clone(), @@ -482,6 +606,11 @@ impl App { return; } self.config = config; + self.context_limit = self.config.active_generation().context_tokens.max(0) as u32; + #[cfg(target_os = "macos")] + { + self.agent_tools = None; + } #[cfg(target_os = "macos")] if dev_brain_changed { self.invalidate_dev_brain_context(); @@ -507,33 +636,65 @@ impl App { ) -> Result> { match message { Message::PreferenceModelChanged(model) => { - self.preference_draft.model = model; - if !model.supports_dspark() { - self.preference_draft.legacy_mtp_enabled = false; - self.preference_draft.dspark_enabled = false; - self.preference_draft.dspark_confidence_threshold.clear(); - self.preference_draft.dspark_strict = false; + if let Err(error) = self.preference_draft.store_generation() { + self.preference_error = Some(error); + return Err(Task::none()); } + if let Err(error) = self.preference_draft.store_acceleration() { + self.preference_error = Some(error); + return Err(Task::none()); + } + let mode = self.preference_draft.reasoning_mode_for(model); + self.preference_draft.model = model; + self.preference_draft.default_reasoning_mode = mode; + self.preference_draft.load_generation(model, mode); + self.preference_draft.load_acceleration(model); if model == ModelChoice::Glm52 { self.preference_draft.power_percent.clear(); self.preference_draft.prefill_chunk.clear(); self.preference_draft.directional_steering_file.clear(); self.preference_draft.directional_steering_ffn.clear(); self.preference_draft.directional_steering_attn.clear(); - } else { - self.preference_draft.glm_mtp = false; - self.preference_draft.glm_mtp_timing = false; - self.preference_draft.ssd_full_layers.clear(); } self.preference_error = None; } + Message::PreferenceDefaultReasoningChanged(mode) => { + if self.preference_draft.generation_model == self.preference_draft.model + && let Err(error) = self + .preference_draft + .select_generation(self.preference_draft.model, mode) + { + self.preference_error = Some(error); + return Err(Task::none()); + } + self.preference_draft.default_reasoning_mode = mode; + self.preference_draft + .model_profiles + .entry(self.preference_draft.model) + .or_default() + .reasoning_mode = mode; + self.preference_error = None; + } + Message::PreferenceGenerationModelChanged(model) => { + let mode = self.preference_draft.reasoning_mode_for(model); + self.preference_error = self.preference_draft.select_generation(model, mode).err(); + } + Message::PreferenceGenerationReasoningChanged(mode) => { + self.preference_error = self + .preference_draft + .select_generation(self.preference_draft.generation_model, mode) + .err(); + } + Message::PreferenceAccelerationModelChanged(model) => { + self.preference_error = self.preference_draft.select_acceleration(model).err(); + } Message::PreferencePermissionModeChanged(mode) => { self.preference_draft.default_permission_mode = mode; self.preference_error = None; } Message::PreferenceLegacyMtpChanged(enabled) => { self.preference_draft.legacy_mtp_enabled = - self.preference_draft.model.supports_dspark() && enabled; + self.preference_draft.acceleration_model.supports_dspark() && enabled; if self.preference_draft.legacy_mtp_enabled { self.preference_draft.dspark_enabled = false; self.preference_draft.dspark_confidence_threshold.clear(); @@ -543,7 +704,7 @@ impl App { } Message::PreferenceDsparkChanged(enabled) => { self.preference_draft.dspark_enabled = - self.preference_draft.model.supports_dspark() && enabled; + self.preference_draft.acceleration_model.supports_dspark() && enabled; if !self.preference_draft.dspark_enabled { self.preference_draft.dspark_confidence_threshold.clear(); self.preference_draft.dspark_strict = false; @@ -675,10 +836,6 @@ impl App { self.preference_draft.seed = value; self.preference_error = None; } - Message::PreferenceReasoningChanged(value) => { - self.preference_draft.reasoning_mode = value; - self.preference_error = None; - } Message::PreferenceCpuThreadsChanged(value) => { self.preference_draft.cpu_threads = value; self.preference_error = None; @@ -709,7 +866,7 @@ impl App { } Message::PreferenceGlmMtpChanged(value) => { self.preference_draft.glm_mtp = - self.preference_draft.model == ModelChoice::Glm52 && value; + self.preference_draft.acceleration_model == ModelChoice::Glm52 && value; if !self.preference_draft.glm_mtp { self.preference_draft.glm_mtp_timing = false; } @@ -717,7 +874,7 @@ impl App { } Message::PreferenceGlmMtpTimingChanged(value) => { self.preference_draft.glm_mtp_timing = - self.preference_draft.model == ModelChoice::Glm52 && value; + self.preference_draft.acceleration_model == ModelChoice::Glm52 && value; if self.preference_draft.glm_mtp_timing { self.preference_draft.glm_mtp = true; } @@ -725,7 +882,7 @@ impl App { } Message::PreferenceDsparkConfidenceChanged(value) => { self.preference_draft.dspark_confidence_threshold = value; - if self.preference_draft.model.supports_dspark() + if self.preference_draft.acceleration_model.supports_dspark() && !self .preference_draft .dspark_confidence_threshold @@ -739,7 +896,7 @@ impl App { } Message::PreferenceDsparkStrictChanged(value) => { self.preference_draft.dspark_strict = - self.preference_draft.model.supports_dspark() && value; + self.preference_draft.acceleration_model.supports_dspark() && value; if self.preference_draft.dspark_strict { self.preference_draft.dspark_enabled = true; self.preference_draft.legacy_mtp_enabled = false; @@ -759,7 +916,7 @@ impl App { self.preference_error = None; } Message::PreferenceSsdFullLayersChanged(value) => { - if self.preference_draft.model == ModelChoice::Glm52 { + if self.preference_draft.acceleration_model == ModelChoice::Glm52 { self.preference_draft.ssd_full_layers = value; } self.preference_error = None; @@ -842,6 +999,30 @@ mod tests { ); } + #[test] + fn selectors_rehydrate_their_generation_and_model_profiles() { + let mut draft = PreferenceDraft::from_saved(&Config::default()); + draft.context_tokens = "123".into(); + draft.ssd_streaming = true; + + draft + .select_generation(ModelChoice::Glm52, ReasoningMode::Direct) + .unwrap(); + assert_eq!(draft.context_tokens, "32768"); + draft.context_tokens = "456".into(); + draft + .select_generation(ModelChoice::DeepSeekV4Flash, ReasoningMode::High) + .unwrap(); + assert_eq!(draft.context_tokens, "123"); + + draft.select_acceleration(ModelChoice::Glm52).unwrap(); + assert!(!draft.ssd_streaming); + draft + .select_acceleration(ModelChoice::DeepSeekV4Flash) + .unwrap(); + assert!(draft.ssd_streaming); + } + #[test] fn runtime_config_updates_after_lock_poisoning() { let runtime = Arc::new(RwLock::new(Config::default())); diff --git a/src/app/projects.rs b/src/app/projects.rs index 64ae88c..de0ca45 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -180,7 +180,7 @@ impl App { self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; - self.context_limit = self.config.generation.context_tokens.max(0) as u32; + self.context_limit = self.config.active_generation().context_tokens.max(0) as u32; self.tokens_per_second = None; self.error = None; } diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index ed42267..de5c5a6 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -307,12 +307,12 @@ impl App { .text_size(12) .padding([2, 6]) }); - let reasoning_mode = self.config.generation.effective_reasoning_mode(); + let reasoning_mode = self.config.reasoning_mode(self.config.model); let reasoning_control: Element<'_, Message> = if self.active_chat_count() > 0 { text(reasoning_mode.to_string()).size(12).into() } else { pick_list( - self.config.generation.supported_reasoning_modes(), + self.config.supported_reasoning_modes(self.config.model), Some(reasoning_mode), Message::ReasoningModeChanged, ) diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index 9b49f34..efd45f9 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -5,7 +5,7 @@ impl App { pub(super) fn preferences_panel(&self) -> Element<'_, Message> { let legacy_mtp_toggle: Option Message> = self .preference_draft - .model + .acceleration_model .supports_dspark() .then_some(Message::PreferenceLegacyMtpChanged); let legacy_mtp = hint( @@ -16,7 +16,7 @@ impl App { ); let dspark_toggle: Option Message> = self .preference_draft - .model + .acceleration_model .supports_dspark() .then_some(Message::PreferenceDsparkChanged); let dspark = hint( @@ -25,33 +25,44 @@ impl App { .on_toggle_maybe(dspark_toggle), "Speculative decoding with the managed DSpark draft artifact: a small model proposes tokens that the main model verifies in one pass. Usually a large speedup; the target model may also stream routed experts from SSD.", ); - let glm_mtp_toggle: Option Message> = (self.preference_draft.model - == ModelChoice::Glm52) - .then_some(Message::PreferenceGlmMtpChanged); - let glm_mtp_timing_toggle: Option Message> = (self.preference_draft.model - == ModelChoice::Glm52) - .then_some(Message::PreferenceGlmMtpTimingChanged); + let glm_mtp_toggle: Option Message> = + (self.preference_draft.acceleration_model == ModelChoice::Glm52) + .then_some(Message::PreferenceGlmMtpChanged); + let glm_mtp_timing_toggle: Option Message> = + (self.preference_draft.acceleration_model == ModelChoice::Glm52) + .then_some(Message::PreferenceGlmMtpTimingChanged); let dspark_strict_toggle: Option Message> = self .preference_draft - .model + .acceleration_model .supports_dspark() .then_some(Message::PreferenceDsparkStrictChanged); let effective = self .preference_draft - .generation() - .and_then(|generation| { - self.preference_draft.runtime().and_then(|runtime| { - crate::settings::effective_settings( - self.preference_draft.model, - &generation, - &runtime, - &models_path(), - ) - }) - }) + .effective_for( + self.preference_draft.model, + self.preference_draft.default_reasoning_mode, + ) + .ok(); + let generation_effective = self + .preference_draft + .effective_for( + self.preference_draft.generation_model, + self.preference_draft.generation_reasoning_mode, + ) + .ok(); + let acceleration_effective = self + .preference_draft + .effective_for( + self.preference_draft.acceleration_model, + self.preference_draft + .reasoning_mode_for(self.preference_draft.acceleration_model), + ) .ok(); let engine = effective.as_ref().map(|settings| &settings.engine); - let turn = effective.as_ref().map(|settings| &settings.turn); + let turn = generation_effective.as_ref().map(|settings| &settings.turn); + let acceleration_engine = acceleration_effective + .as_ref() + .map(|settings| &settings.engine); let mut power = text_input("100", &self.preference_draft.power_percent); let mut prefill = text_input("Automatic", &self.preference_draft.prefill_chunk); let mut ssd_full_layers = text_input("Automatic", &self.preference_draft.ssd_full_layers); @@ -72,10 +83,11 @@ impl App { steering_file = steering_file.on_input(Message::PreferenceSteeringFileChanged); steering_ffn = steering_ffn.on_input(Message::PreferenceSteeringFfnChanged); steering_attn = steering_attn.on_input(Message::PreferenceSteeringAttnChanged); - } else { + } + if self.preference_draft.acceleration_model == ModelChoice::Glm52 { ssd_full_layers = ssd_full_layers.on_input(Message::PreferenceSsdFullLayersChanged); } - if self.preference_draft.model.supports_dspark() { + if self.preference_draft.acceleration_model.supports_dspark() { dspark_confidence = dspark_confidence.on_input(Message::PreferenceDsparkConfidenceChanged); } @@ -93,6 +105,20 @@ impl App { .width(Length::Fill), "Which local weights the Metal engine loads for chats and for the HTTP endpoint. The choice decides which accelerations below apply, and the matching artifacts must be downloaded in the model manager.", ), + row![ + hint( + text("Default thinking mode").size(13).width(Length::Fill), + "Thinking mode selected when this model becomes active. Chat can switch it for later turns.", + ), + pick_list( + &REASONING_MODES[..], + Some(self.preference_draft.default_reasoning_mode), + Message::PreferenceDefaultReasoningChanged, + ) + .width(240), + ] + .spacing(12) + .align_y(Alignment::Center), text(format!( "Main: {}{}", engine.map_or_else( @@ -282,10 +308,49 @@ impl App { ] .spacing(10), ); + let prompt_group = preference_group( + PreferenceSection::Prompt, + "PROMPT", + column![ + hint( + text("System prompt").size(13), + "Standing instruction sent before every conversation: persona, tone, house rules. Leave it empty to send no system message at all.", + ), + text_editor(&self.preference_draft.system_prompt) + .placeholder("Additional system instructions…") + .height(140) + .on_action(Message::PreferenceSystemPromptAction) + .padding(9), + text("The system prompt is shared by every model and thinking mode.").size(12), + ] + .spacing(10), + ); let generation_group = preference_group( PreferenceSection::Generation, "GENERATION", column![ + row![ + text("Model").size(13).width(Length::Fill), + pick_list( + &MODEL_CHOICES[..], + Some(self.preference_draft.generation_model), + Message::PreferenceGenerationModelChanged, + ) + .width(400), + ] + .spacing(12) + .align_y(Alignment::Center), + row![ + text("Thinking mode").size(13).width(Length::Fill), + pick_list( + &REASONING_MODES[..], + Some(self.preference_draft.generation_reasoning_mode), + Message::PreferenceGenerationReasoningChanged, + ) + .width(240), + ] + .spacing(12) + .align_y(Alignment::Center), preference_input_row( "Context tokens", "Size of the window the model can see: system prompt, history, the new question and the answer all have to fit. Larger windows allow longer sessions but reserve much more memory for the key-value cache.", @@ -298,17 +363,7 @@ impl App { text_input("50000", &self.preference_draft.max_generated_tokens) .on_input(Message::PreferenceMaxTokensChanged), ), - hint( - text("System prompt").size(13), - "Standing instruction sent before every conversation: persona, tone, house rules. Leave it empty to send no system message at all.", - ), - text_editor(&self.preference_draft.system_prompt) - .placeholder("Additional system instructions…") - .height(140) - .on_action(Message::PreferenceSystemPromptAction) - .padding(9), - Space::new().height(4), - text("SAMPLING & REASONING").size(11).color(muted_text()), + text("SAMPLING").size(11).color(muted_text()), preference_input_row( "Temperature", "How adventurous token choice is. Near 0 the model repeats the most likely continuation, which suits code and extraction; higher values invent more and drift more.", @@ -333,20 +388,6 @@ impl App { text_input("Random", &self.preference_draft.seed) .on_input(Message::PreferenceSeedChanged), ), - row![ - hint( - text("Reasoning").size(13).width(Length::Fill), - "How much hidden thinking precedes the answer. Direct skips it and replies fastest, Thinking is the balanced default, Think Max reasons longest and needs at least 393216 context tokens.", - ), - pick_list( - &REASONING_MODES[..], - Some(self.preference_draft.reasoning_mode), - Message::PreferenceReasoningChanged, - ) - .width(240), - ] - .spacing(12) - .align_y(Alignment::Center), text("Blank sampling values retain DS4's model-family defaults. Think Max needs at least 393216 context tokens.") .size(12), text(turn.map_or_else( @@ -428,6 +469,17 @@ impl App { PreferenceSection::Acceleration, "ACCELERATION & MEMORY", column![ + row![ + text("Model").size(13).width(Length::Fill), + pick_list( + &MODEL_CHOICES[..], + Some(self.preference_draft.acceleration_model), + Message::PreferenceAccelerationModelChanged, + ) + .width(400), + ] + .spacing(12) + .align_y(Alignment::Center), text("SPECULATIVE DECODING").size(11).color(muted_text()), preference_input_row( "MTP draft tokens", @@ -466,15 +518,15 @@ impl App { .on_toggle_maybe(dspark_strict_toggle), "Lets the draft model only propose, never decide: every token is sampled by the full model. Gives up some of the speedup in exchange for output identical to non-speculative decoding.", ), - text(if self.preference_draft.model.supports_dspark() { + text(if self.preference_draft.acceleration_model.supports_dspark() { "Legacy MTP and DSpark use separate managed support artifacts; entering a DSpark threshold or enabling strict mode selects DSpark." - } else if self.preference_draft.model == ModelChoice::Glm52 { + } else if self.preference_draft.acceleration_model == ModelChoice::Glm52 { "GLM MTP is integrated; DSpark is unavailable for this model." } else { "No managed MTP support artifact is available for this model." }) .size(12), - text(engine.as_ref().map_or_else( + text(acceleration_engine.map_or_else( || "Effective speculative settings will appear after valid values are entered." .to_owned(), |engine| { @@ -527,7 +579,7 @@ impl App { ), text("A blank full-layer value is automatic; an explicit 0 disables fully resident GLM layers. Flash legacy MTP and DSpark support weights remain resident when target experts stream.") .size(12), - text(engine.as_ref().map_or_else( + text(acceleration_engine.map_or_else( || "Effective SSD settings will appear after valid values are entered." .to_owned(), |engine| { @@ -680,6 +732,7 @@ impl App { endpoint_group, dev_brain_group, git_group, + prompt_group, generation_group, execution_group, acceleration_group, diff --git a/src/config.rs b/src/config.rs index 4bbab01..7710f4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,23 @@ use serde::{Deserialize, Serialize}; use serde_norway::{Mapping, Value}; +use std::collections::BTreeMap; use std::fmt; use std::fs; use std::path::Path; -use crate::model::ModelChoice; -use crate::settings::{GenerationPreferences, RuntimePreferences}; +use crate::model::{MODEL_CHOICES, ModelChoice}; +use crate::settings::{ + DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode, + RuntimePreferences, SpeculativePreferences, SsdPreferences, +}; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct ModelPreferences { + pub(crate) reasoning_mode: ReasoningMode, + pub(crate) speculative: SpeculativePreferences, + pub(crate) ssd: SsdPreferences, +} /// 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 @@ -19,7 +31,10 @@ pub struct Config { pub a2ui_enabled: bool, pub dev_brain: DevBrainConfig, pub endpoint: EndpointConfig, - pub generation: GenerationPreferences, + pub system_prompt: String, + pub(crate) generation_profiles: + BTreeMap>, + pub(crate) model_profiles: BTreeMap, pub runtime: RuntimePreferences, pub git: GitConfig, pub interface: InterfaceConfig, @@ -34,7 +49,23 @@ impl Default for Config { a2ui_enabled: true, dev_brain: DevBrainConfig::default(), endpoint: EndpointConfig::default(), - generation: GenerationPreferences::default(), + system_prompt: DEFAULT_SYSTEM_PROMPT.into(), + generation_profiles: MODEL_CHOICES + .into_iter() + .map(|model| { + ( + model, + REASONING_MODES + .into_iter() + .map(|mode| (mode, GenerationPreferences::default())) + .collect(), + ) + }) + .collect(), + model_profiles: MODEL_CHOICES + .into_iter() + .map(|model| (model, ModelPreferences::default())) + .collect(), runtime: RuntimePreferences::default(), git: GitConfig::default(), interface: InterfaceConfig::default(), @@ -258,8 +289,12 @@ impl Config { } Err(error) => return Err(format!("Could not read {}: {error}", path.display())), }; - let config: Self = serde_norway::from_str(&text) + let mut value: Value = serde_norway::from_str(&text) .map_err(|error| format!("Could not read {}: {error}", path.display()))?; + drop_legacy_model_settings(&mut value); + let mut config: Self = serde_norway::from_value(value) + .map_err(|error| format!("Could not read {}: {error}", path.display()))?; + config.fill_profile_defaults(); config.validate()?; Ok(config) } @@ -286,10 +321,100 @@ impl Config { 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)?; + for (model, profiles) in &self.generation_profiles { + for (mode, generation) in profiles { + let mut generation = generation.clone(); + generation.system_prompt = self.system_prompt.clone(); + generation.reasoning_mode = *mode; + generation.validate().map_err(|error| { + format!("Generation settings for {model} / {mode}: {error}") + })?; + } + } + for (model, preferences) in &self.model_profiles { + preferences.speculative.validate(*model)?; + preferences.ssd.validate(*model)?; + if preferences.reasoning_mode == ReasoningMode::Max + && !self + .supported_reasoning_modes(*model) + .contains(&ReasoningMode::Max) + { + return Err(format!( + "Default thinking mode for {model} requires its Think Max profile to use at least 393216 context tokens." + )); + } + } + self.runtime_for(self.model).validate(self.model)?; self.dev_brain.validate() } + + pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode { + self.model_profiles + .get(&model) + .map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) + } + + pub(crate) fn generation_for( + &self, + model: ModelChoice, + reasoning_mode: ReasoningMode, + ) -> GenerationPreferences { + let mut generation = self + .generation_profiles + .get(&model) + .and_then(|profiles| profiles.get(&reasoning_mode)) + .cloned() + .unwrap_or_default(); + generation.system_prompt = self.system_prompt.clone(); + generation.reasoning_mode = reasoning_mode; + generation + } + + pub(crate) fn active_generation(&self) -> GenerationPreferences { + self.generation_for(self.model, self.reasoning_mode(self.model)) + } + + pub(crate) fn runtime_for(&self, model: ModelChoice) -> RuntimePreferences { + let mut runtime = self.runtime.clone(); + if let Some(profile) = self.model_profiles.get(&model) { + runtime.speculative = profile.speculative.clone(); + runtime.ssd = profile.ssd.clone(); + } + runtime + } + + pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] { + let max = self.generation_for(model, ReasoningMode::Max); + max.supported_reasoning_modes() + } + + fn fill_profile_defaults(&mut self) { + for model in MODEL_CHOICES { + self.model_profiles.entry(model).or_default(); + let profiles = self.generation_profiles.entry(model).or_default(); + for mode in REASONING_MODES { + profiles.entry(mode).or_default(); + } + } + } +} + +fn drop_legacy_model_settings(value: &mut Value) { + let Value::Mapping(root) = value else { + return; + }; + let generation_key = Value::String("generation".into()); + let prompt_key = Value::String("system_prompt".into()); + if let Some(Value::Mapping(mut generation)) = root.remove(&generation_key) + && !root.contains_key(&prompt_key) + && let Some(prompt) = generation.remove(&prompt_key) + { + root.insert(prompt_key, prompt); + } + if let Some(Value::Mapping(runtime)) = root.get_mut(Value::String("runtime".into())) { + runtime.remove(Value::String("speculative".into())); + runtime.remove(Value::String("ssd".into())); + } } /// Drops every value that still matches the default, so the file lists only what @@ -299,8 +424,11 @@ fn without_defaults(value: Value, defaults: &Value) -> Option { let kept = entries .iter() .filter_map(|(key, entry)| { - let default = defaults.get(key)?; - Some((key.clone(), without_defaults(entry.clone(), default)?)) + let kept = match defaults.get(key) { + Some(default) => without_defaults(entry.clone(), default)?, + None => entry.clone(), + }; + Some((key.clone(), kept)) }) .collect::(); return (!kept.is_empty()).then_some(Value::Mapping(kept)); @@ -328,23 +456,10 @@ mod tests { 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 { + let mut config = Config { model: ModelChoice::Glm52, default_permission_mode: PermissionMode::Ai, a2ui_enabled: false, - 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() - }, git: GitConfig { diff_layout: GitDiffLayout::Split, diff_algorithm: GitDiffAlgorithm::Patience, @@ -359,14 +474,28 @@ mod tests { }, ..Config::default() }; + config + .generation_profiles + .get_mut(&ModelChoice::Glm52) + .unwrap() + .get_mut(&ReasoningMode::Direct) + .unwrap() + .context_tokens = 65_536; + let model = config.model_profiles.get_mut(&ModelChoice::Glm52).unwrap(); + model.reasoning_mode = ReasoningMode::Direct; + model.ssd = SsdPreferences { + enabled: true, + cache: Some(StreamingCacheBudget::Gib(64)), + ..SsdPreferences::default() + }; config.save(&path).unwrap(); let text = fs::read_to_string(&path).unwrap(); assert_eq!( text, "model: glm-5.2\ndefault_permission_mode: ai\na2ui_enabled: false\n\ - generation:\n context_tokens: 65536\n reasoning_mode: none\n\ - runtime:\n ssd:\n enabled: true\n cache: 64GB\n\ + generation_profiles:\n glm-5.2:\n none:\n context_tokens: 65536\n\ + model_profiles:\n glm-5.2:\n reasoning_mode: none\n ssd:\n enabled: true\n cache: 64GB\n\ git:\n diff_layout: split\n diff_algorithm: patience\n context_lines: 5\n whitespace: ignore-end-of-line\n\ interface:\n window_size:\n - 1280\n - 800\n window_position:\n - 120\n - -40\n" ); @@ -374,6 +503,26 @@ mod tests { fs::remove_dir_all(&directory).unwrap(); } + #[test] + fn legacy_flat_model_settings_are_dropped_but_the_prompt_is_kept() { + let directory = + std::env::temp_dir().join(format!("ds4-config-legacy-{}", std::process::id())); + let path = directory.join("config.yaml"); + fs::create_dir_all(&directory).unwrap(); + fs::write( + &path, + "generation:\n system_prompt: keep me\n temperature: 0.2\nruntime:\n execution:\n quality: true\n ssd:\n enabled: true\n", + ) + .unwrap(); + + let config = Config::load(&path).unwrap(); + assert_eq!(config.system_prompt, "keep me"); + assert_eq!(config.active_generation().temperature, None); + assert!(!config.runtime_for(config.model).ssd.enabled); + assert!(config.runtime.execution.quality); + 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())); diff --git a/src/model.rs b/src/model.rs index e648935..046260d 100644 --- a/src/model.rs +++ b/src/model.rs @@ -77,7 +77,7 @@ const GLM: Artifact = Artifact { support: Some(false), }; -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub(crate) enum ModelChoice { #[default] #[serde(rename = "deepseek-v4-flash")] diff --git a/src/server.rs b/src/server.rs index 84c6f13..1076e57 100644 --- a/src/server.rs +++ b/src/server.rs @@ -25,7 +25,7 @@ use crate::engine::ChatTurn; use crate::metrics::{Metrics, WorkSource}; use crate::model::{self, ModelChoice}; use crate::runtime::{CheckpointTarget, GenerationEvent, GenerationService}; -use crate::settings::{ReasoningMode, effective_settings}; +use crate::settings::{GenerationPreferences, ReasoningMode, effective_settings}; use serde::Deserialize; use serde_json::value::RawValue; use serde_json::{Map, Value, json}; @@ -375,10 +375,8 @@ fn handle(mut stream: TcpStream, state: &State) { let model = model_alias(id).filter(|model| installed.contains(model)); if let Some(model) = model { let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| { - ( - config.generation.context_tokens, - config.generation.max_generated_tokens, - ) + let generation = config.generation_for(model, config.reasoning_mode(model)); + (generation.context_tokens, generation.max_generated_tokens) }); send_json_with_cors( &mut stream, @@ -505,17 +503,23 @@ fn installed_endpoint_models(models_path: &std::path::Path) -> Vec } fn models_json(state: &State) -> Value { - let (context, tokens) = state.config.read().map_or((32_768, 32_768), |config| { - ( - config.generation.context_tokens, - config.generation.max_generated_tokens, - ) - }); + let config = state.config.read().ok().map(|config| config.clone()); json!({ "object": "list", "data": installed_endpoint_models(&state.models_path) .into_iter() - .map(|model| model_json(model.id(), model, context, tokens)) + .map(|model| { + let generation = config.as_ref().map_or_else( + GenerationPreferences::default, + |config| config.generation_for(model, config.reasoning_mode(model)), + ); + model_json( + model.id(), + model, + generation.context_tokens, + generation.max_generated_tokens, + ) + }) .collect::>() }) } diff --git a/src/server/request.rs b/src/server/request.rs index b114cbd..a2b2423 100644 --- a/src/server/request.rs +++ b/src/server/request.rs @@ -499,8 +499,8 @@ pub(super) fn parse_chat_request( return Err((400, format!("model is not installed and verified: {model}"))); } - let mut generation = config.generation.clone(); - generation.system_prompt.clear(); + let reasoning_mode = request_reasoning(&request, requested_id)?; + let mut generation = config.generation_for(model, reasoning_mode); generation.max_generated_tokens = request .max_completion_tokens .or(request.max_tokens) @@ -512,7 +512,6 @@ pub(super) fn parse_chat_request( generation.top_p = request.top_p.or(Some(1.0)); generation.min_p = request.min_p.or(Some(0.05)); generation.seed = request.seed.filter(|seed| *seed > 0); - generation.reasoning_mode = request_reasoning(&request, requested_id)?; let tools_enabled = (!request.tools.is_empty() || !request.tool_schemas.is_empty()) && request.tool_choice.as_ref().and_then(Value::as_str) != Some("none"); @@ -525,7 +524,8 @@ pub(super) fn parse_chat_request( protocol, )?; generation.system_prompt = system; - let mut effective = effective_settings(model, &generation, &config.runtime, &state.models_path) + let runtime = config.runtime_for(model); + let mut effective = effective_settings(model, &generation, &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 { diff --git a/src/settings.rs b/src/settings.rs index 8dd3613..9c0993c 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -380,7 +380,9 @@ pub(crate) struct EngineDiagnosticSettings { #[serde(default, deny_unknown_fields)] pub(crate) struct RuntimePreferences { pub(crate) execution: ExecutionPreferences, + #[serde(skip)] pub(crate) speculative: SpeculativePreferences, + #[serde(skip)] pub(crate) ssd: SsdPreferences, pub(crate) steering: SteeringPreferences, pub(crate) diagnostics: DiagnosticPreferences, @@ -505,7 +507,7 @@ pub(crate) struct EngineExecutionSettings { pub(crate) warm_weights: bool, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "lowercase")] pub(crate) enum ReasoningMode { #[serde(rename = "none")] @@ -530,22 +532,28 @@ impl fmt::Display for ReasoningMode { pub(crate) struct GenerationPreferences { pub(crate) context_tokens: i32, pub(crate) max_generated_tokens: i32, + #[serde(skip_serializing, default = "default_system_prompt")] pub(crate) system_prompt: String, pub(crate) temperature: Option, pub(crate) top_p: Option, pub(crate) min_p: Option, pub(crate) seed: Option, + #[serde(skip_serializing)] pub(crate) reasoning_mode: ReasoningMode, } pub(crate) const DEFAULT_SYSTEM_PROMPT: &str = "You are an expert coding assistant operating inside DS4Server, a local coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nGuidelines:\n- Be concise in your responses\n- Show file paths clearly when working with files"; +fn default_system_prompt() -> String { + DEFAULT_SYSTEM_PROMPT.into() +} + impl Default for GenerationPreferences { fn default() -> Self { Self { context_tokens: 32_768, max_generated_tokens: 50_000, - system_prompt: DEFAULT_SYSTEM_PROMPT.into(), + system_prompt: default_system_prompt(), temperature: None, top_p: None, min_p: None,