diff --git a/PLAN.md b/PLAN.md index d6237f1..a1d5fa1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,9 +45,9 @@ Exit criterion: restart the app and see the same project/session tree. ## Phase 1 — model library and on-demand loading -Status: **partially implemented**. Basic preferences and the complete -background download workflow are implemented; DS4 CLI preference parity, -loading, inference, and idle unloading remain open. +Status: **partially implemented**. Basic preferences, typed generation defaults, +and the complete background download workflow are implemented; the remaining +DS4 runtime preferences, loading, inference, and idle unloading remain open. 1. Complete the typed model/runtime preference contract described below before connecting chat, the HTTP endpoint, or the engine lifecycle. diff --git a/migrations/20260724180000_add_generation_preferences/down.sql b/migrations/20260724180000_add_generation_preferences/down.sql new file mode 100644 index 0000000..6584b92 --- /dev/null +++ b/migrations/20260724180000_add_generation_preferences/down.sql @@ -0,0 +1,8 @@ +ALTER TABLE preferences DROP COLUMN reasoning_mode; +ALTER TABLE preferences DROP COLUMN seed; +ALTER TABLE preferences DROP COLUMN min_p; +ALTER TABLE preferences DROP COLUMN top_p; +ALTER TABLE preferences DROP COLUMN temperature; +ALTER TABLE preferences DROP COLUMN system_prompt; +ALTER TABLE preferences DROP COLUMN max_generated_tokens; +ALTER TABLE preferences DROP COLUMN context_tokens; diff --git a/migrations/20260724180000_add_generation_preferences/up.sql b/migrations/20260724180000_add_generation_preferences/up.sql new file mode 100644 index 0000000..cc53ee9 --- /dev/null +++ b/migrations/20260724180000_add_generation_preferences/up.sql @@ -0,0 +1,14 @@ +ALTER TABLE preferences ADD COLUMN context_tokens INTEGER NOT NULL DEFAULT 32768 + CHECK (context_tokens > 0); +ALTER TABLE preferences ADD COLUMN max_generated_tokens INTEGER NOT NULL DEFAULT 50000 + CHECK (max_generated_tokens > 0); +ALTER TABLE preferences ADD COLUMN system_prompt TEXT NOT NULL DEFAULT 'You are a helpful assistant'; +ALTER TABLE preferences ADD COLUMN temperature REAL + CHECK (temperature IS NULL OR temperature BETWEEN 0 AND 100); +ALTER TABLE preferences ADD COLUMN top_p REAL + CHECK (top_p IS NULL OR top_p BETWEEN 0 AND 1); +ALTER TABLE preferences ADD COLUMN min_p REAL + CHECK (min_p IS NULL OR min_p BETWEEN 0 AND 1); +ALTER TABLE preferences ADD COLUMN seed TEXT; +ALTER TABLE preferences ADD COLUMN reasoning_mode TEXT NOT NULL DEFAULT 'high' + CHECK (reasoning_mode IN ('none', 'high', 'max')); diff --git a/src/app.rs b/src/app.rs index 45d4c8f..d1f872f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ pub(crate) use view::app_theme; use crate::database::{AppPreferences, Database, ProjectWithSessions}; use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice}; +use crate::settings::{GenerationPreferences, ReasoningMode}; use iced::{Size, Subscription, Task, keyboard, window}; use rfd::AsyncFileDialog; use std::fs; @@ -21,18 +22,109 @@ struct PreferenceDraft { model: ModelChoice, dspark_enabled: bool, idle_timeout_minutes: String, + context_tokens: String, + max_generated_tokens: String, + system_prompt: String, + temperature: String, + top_p: String, + min_p: String, + seed: String, + reasoning_mode: ReasoningMode, } impl PreferenceDraft { 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()?; Ok(Self { model, dspark_enabled: model.supports_dspark() && preferences.dspark_enabled, idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(), + 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()), + reasoning_mode: generation.reasoning_mode, }) } + + fn generation(&self) -> Result { + let preferences = GenerationPreferences { + context_tokens: parse_positive_i32("Context tokens", &self.context_tokens)?, + max_generated_tokens: parse_positive_i32( + "Maximum generated tokens", + &self.max_generated_tokens, + )?, + system_prompt: self.system_prompt.clone(), + temperature: parse_optional_f32("Temperature", &self.temperature)?, + 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, + }; + preferences.validate()?; + Ok(preferences) + } + + fn reset(&mut self) { + let defaults = GenerationPreferences::default(); + self.model = ModelChoice::default(); + self.dspark_enabled = false; + self.idle_timeout_minutes = "10".into(); + 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; + } +} + +fn parse_positive_i32(name: &str, value: &str) -> Result { + value + .trim() + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{name} must be a positive whole number.")) +} + +fn parse_optional_f32(name: &str, value: &str) -> Result, String> { + let value = value.trim(); + if value.is_empty() { + Ok(None) + } else { + value + .parse() + .map(Some) + .map_err(|_| format!("{name} must be a number or left blank for the DS4 default.")) + } +} + +fn parse_optional_u64(name: &str, value: &str) -> Result, String> { + let value = value.trim(); + if value.is_empty() { + Ok(None) + } else { + value + .parse() + .map(Some) + .map_err(|_| format!("{name} must be a positive whole number or left blank.")) + } } pub(crate) struct App { @@ -96,6 +188,15 @@ pub(crate) enum Message { PreferenceModelChanged(ModelChoice), PreferenceDsparkChanged(bool), PreferenceTimeoutChanged(String), + PreferenceContextChanged(String), + PreferenceMaxTokensChanged(String), + PreferenceSystemPromptChanged(String), + PreferenceTemperatureChanged(String), + PreferenceTopPChanged(String), + PreferenceMinPChanged(String), + PreferenceSeedChanged(String), + PreferenceReasoningChanged(ReasoningMode), + ResetPreferences, SavePreferences, DownloadArtifact(ManagedArtifactId), ValidateArtifact(ManagedArtifactId), @@ -237,6 +338,42 @@ impl App { self.preference_draft.idle_timeout_minutes = value; self.preference_error = None; } + Message::PreferenceContextChanged(value) => { + self.preference_draft.context_tokens = value; + self.preference_error = None; + } + Message::PreferenceMaxTokensChanged(value) => { + self.preference_draft.max_generated_tokens = value; + self.preference_error = None; + } + Message::PreferenceSystemPromptChanged(value) => { + self.preference_draft.system_prompt = value; + self.preference_error = None; + } + Message::PreferenceTemperatureChanged(value) => { + self.preference_draft.temperature = value; + self.preference_error = None; + } + Message::PreferenceTopPChanged(value) => { + self.preference_draft.top_p = value; + self.preference_error = None; + } + Message::PreferenceMinPChanged(value) => { + self.preference_draft.min_p = value; + self.preference_error = None; + } + Message::PreferenceSeedChanged(value) => { + self.preference_draft.seed = value; + self.preference_error = None; + } + Message::PreferenceReasoningChanged(value) => { + self.preference_draft.reasoning_mode = value; + self.preference_error = None; + } + Message::ResetPreferences => { + self.preference_draft.reset(); + self.preference_error = None; + } Message::SavePreferences => self.save_preferences(), Message::DownloadArtifact(artifact) => { self.start_model_operation(artifact, ModelOperation::Download) @@ -464,13 +601,25 @@ impl App { self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into()); return; } + let generation = match self.preference_draft.generation() { + Ok(generation) => generation, + Err(error) => { + self.preference_error = Some(error); + return; + } + }; let model = self.preference_draft.model; let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled; let Some(database) = &mut self.database else { return; }; - match database.update_preferences(model.id(), dspark_enabled, idle_timeout_minutes) { + match database.update_preferences( + model.id(), + dspark_enabled, + idle_timeout_minutes, + &generation, + ) { Ok(preferences) => { self.preferences = preferences; self.preference_draft = PreferenceDraft::from_saved(&self.preferences) diff --git a/src/app/view.rs b/src/app/view.rs index a075d5d..0758f15 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -3,6 +3,7 @@ use crate::database::{ProjectWithSessions, Session}; use crate::model::{ self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, }; +use crate::settings::REASONING_MODES; use iced::theme::{Palette, palette}; use iced::widget::{ Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list, @@ -299,17 +300,13 @@ impl App { self.preference_draft.dspark_enabled, ) .on_toggle_maybe(dspark_toggle); + let effective = self + .preference_draft + .generation() + .ok() + .map(|generation| generation.effective(self.preference_draft.model)); - let mut content = column![ - row![ - icon(ICON_SETTINGS, 22), - text("Preferences").size(24), - Space::with_width(Length::Fill), - text("⌘,").size(12), - ] - .spacing(10) - .align_y(Alignment::Center), - Space::with_height(6), + let mut fields = column![ text("MODEL").size(11), pick_list( &MODEL_CHOICES[..], @@ -325,6 +322,75 @@ impl App { }) .size(12), Space::with_height(8), + text("CAPACITY").size(11), + preference_input_row( + "Context tokens", + text_input("32768", &self.preference_draft.context_tokens) + .on_input(Message::PreferenceContextChanged), + ), + preference_input_row( + "Maximum generated tokens", + text_input("50000", &self.preference_draft.max_generated_tokens) + .on_input(Message::PreferenceMaxTokensChanged), + ), + text("System prompt").size(13), + text_input( + "You are a helpful assistant", + &self.preference_draft.system_prompt, + ) + .on_input(Message::PreferenceSystemPromptChanged) + .padding(9), + Space::with_height(8), + text("SAMPLING AND REASONING").size(11), + preference_input_row( + "Temperature", + text_input("DS4 default", &self.preference_draft.temperature) + .on_input(Message::PreferenceTemperatureChanged), + ), + preference_input_row( + "Top-p", + text_input("DS4 default", &self.preference_draft.top_p) + .on_input(Message::PreferenceTopPChanged), + ), + preference_input_row( + "Min-p", + text_input("DS4 default", &self.preference_draft.min_p) + .on_input(Message::PreferenceMinPChanged), + ), + preference_input_row( + "Seed", + text_input("Random", &self.preference_draft.seed) + .on_input(Message::PreferenceSeedChanged), + ), + row![ + text("Reasoning").size(13).width(Length::Fill), + 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(effective.map_or_else( + || "Effective settings will appear after valid values are entered.".to_owned(), + |settings| format!( + "Effective: {} context • {} max • temp {} • top-p {} • min-p {} • seed {} • {} • system prompt {}", + settings.context_tokens, + settings.max_generated_tokens, + settings.temperature, + settings.top_p, + settings.min_p, + settings.seed.map_or_else(|| "random".to_owned(), |seed| seed.to_string()), + settings.reasoning_mode, + if settings.system_prompt.is_empty() { "off" } else { "on" }, + ), + )) + .size(12), + Space::with_height(8), text("INACTIVITY").size(11), row![ text_input("10", &self.preference_draft.idle_timeout_minutes) @@ -340,23 +406,33 @@ impl App { .spacing(10); if let Some(error) = &self.preference_error { - content = content.push(text(error).style(iced::widget::text::danger)); + fields = fields.push(text(error).style(iced::widget::text::danger)); } - content = content.push( - row![ - Space::with_width(Length::Fill), - action_button("Cancel").on_press(Message::DismissPanel), - action_button("Save").on_press(Message::SavePreferences), - ] - .spacing(8), - ); + let header = row![ + icon(ICON_SETTINGS, 22), + text("Preferences").size(24), + Space::with_width(Length::Fill), + text("⌘,").size(12), + ] + .spacing(10) + .align_y(Alignment::Center); + let footer = row![ + action_button("Reset DS4 defaults").on_press(Message::ResetPreferences), + Space::with_width(Length::Fill), + action_button("Cancel").on_press(Message::DismissPanel), + action_button("Save").on_press(Message::SavePreferences), + ] + .spacing(8); - let panel = container(content) + let panel = container(column![header, scrollable(fields), footer].spacing(16)) .padding(24) - .width(520) + .width(600) + .height(Length::Fill) + .max_height(660) .style(overview_style); opaque( container(panel) + .padding(24) .center_x(Length::Fill) .center_y(Length::Fill) .style(|_| { @@ -541,6 +617,19 @@ fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static .into() } +fn preference_input_row<'a>( + label: &'a str, + input: iced::widget::TextInput<'a, Message>, +) -> Element<'a, Message> { + row![ + text(label).size(13).width(Length::Fill), + input.width(240).padding(9), + ] + .spacing(12) + .align_y(Alignment::Center) + .into() +} + fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> { let progress = &download.progress; let percent = progress.fraction() * 100.0; diff --git a/src/database.rs b/src/database.rs index 2c8ca60..f2a2962 100644 --- a/src/database.rs +++ b/src/database.rs @@ -5,6 +5,7 @@ use std::fs; use std::path::Path; use crate::schema::{preferences, projects, sessions}; +use crate::settings::{GenerationPreferences, ReasoningMode}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); @@ -16,6 +17,14 @@ pub struct AppPreferences { 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, } impl Default for AppPreferences { @@ -25,16 +34,55 @@ impl Default for AppPreferences { 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(), } } } +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) + } +} + #[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, } #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] @@ -137,12 +185,23 @@ impl Database { selected_model: &str, dspark_enabled: bool, idle_timeout_minutes: i32, + generation: &GenerationPreferences, ) -> Result { + generation.validate()?; + let seed = generation.seed.map(|seed| seed.to_string()); diesel::update(preferences::table.find(1)) .set(PreferenceChanges { selected_model, 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(), }) .returning(AppPreferences::as_returning()) .get_result(&mut self.connection) @@ -202,13 +261,26 @@ mod tests { assert_eq!(preferences.selected_model, "deepseek-v4-flash"); assert!(!preferences.dspark_enabled); assert_eq!(preferences.idle_timeout_minutes, 10); - assert!(database.update_preferences("glm-5.2", true, 30).is_err()); + let generation = GenerationPreferences::default(); assert!( database - .update_preferences("deepseek-v4-flash", false, 0) + .update_preferences("glm-5.2", true, 30, &generation) .is_err() ); - database.update_preferences("glm-5.2", false, 30).unwrap(); + assert!( + database + .update_preferences("deepseek-v4-flash", false, 0, &generation) + .is_err() + ); + let generation = GenerationPreferences { + top_p: Some(0.7), + seed: Some(u64::MAX), + reasoning_mode: ReasoningMode::Max, + ..generation + }; + database + .update_preferences("glm-5.2", false, 30, &generation) + .unwrap(); let project = database.create_project("DS4", "/tmp/ds4").unwrap(); let first = database @@ -229,6 +301,7 @@ mod tests { let preferences = reopened.load_preferences().unwrap(); assert_eq!(preferences.selected_model, "glm-5.2"); assert_eq!(preferences.idle_timeout_minutes, 30); + assert_eq!(preferences.generation().unwrap(), generation); drop(reopened); fs::remove_file(path).unwrap(); } diff --git a/src/main.rs b/src/main.rs index c12acf9..f8208c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod model; #[cfg(target_os = "macos")] mod native_menu; mod schema; +mod settings; use app::{App, Message, app_icon, app_theme}; use iced::{Size, window}; diff --git a/src/schema.rs b/src/schema.rs index 737dbcd..b615cf7 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -12,6 +12,14 @@ diesel::table! { 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, } } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..4765ed9 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,159 @@ +use crate::model::ModelChoice; +use std::fmt; + +pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [ + ReasoningMode::High, + ReasoningMode::Max, + ReasoningMode::Direct, +]; + +const THINK_MAX_MIN_CONTEXT: i32 = 393_216; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ReasoningMode { + 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 { + Self::Direct => "Direct (no thinking)", + Self::High => "Thinking", + Self::Max => "Think Max", + }) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GenerationPreferences { + pub(crate) context_tokens: i32, + pub(crate) max_generated_tokens: i32, + pub(crate) system_prompt: String, + pub(crate) temperature: Option, + pub(crate) top_p: Option, + pub(crate) min_p: Option, + pub(crate) seed: Option, + pub(crate) reasoning_mode: ReasoningMode, +} + +impl Default for GenerationPreferences { + fn default() -> Self { + Self { + 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: ReasoningMode::High, + } + } +} + +impl GenerationPreferences { + pub(crate) fn validate(&self) -> Result<(), String> { + if self.context_tokens <= 0 { + return Err("Context tokens must be a positive whole number.".into()); + } + if self.max_generated_tokens <= 0 { + return Err("Maximum generated tokens must be a positive whole number.".into()); + } + validate_optional_float("Temperature", self.temperature, 0.0, 100.0)?; + validate_optional_float("Top-p", self.top_p, 0.0, 1.0)?; + validate_optional_float("Min-p", self.min_p, 0.0, 1.0)?; + if self.seed == Some(0) { + return Err("Seed must be a positive whole number.".into()); + } + Ok(()) + } + + pub(crate) fn effective(&self, model: ModelChoice) -> EffectiveGenerationSettings { + let glm = model == ModelChoice::Glm52; + EffectiveGenerationSettings { + context_tokens: self.context_tokens, + max_generated_tokens: self.max_generated_tokens, + system_prompt: self.system_prompt.clone(), + temperature: self.temperature.unwrap_or(1.0), + top_p: self.top_p.unwrap_or(if glm { 0.95 } else { 1.0 }), + min_p: self.min_p.unwrap_or(if glm { 0.0 } else { 0.05 }), + seed: self.seed, + reasoning_mode: if self.reasoning_mode == ReasoningMode::Max + && self.context_tokens < THINK_MAX_MIN_CONTEXT + { + ReasoningMode::High + } else { + self.reasoning_mode + }, + } + } +} + +fn validate_optional_float( + name: &str, + value: Option, + minimum: f32, + maximum: f32, +) -> Result<(), String> { + if value.is_some_and(|value| !value.is_finite() || !(minimum..=maximum).contains(&value)) { + return Err(format!("{name} must be between {minimum} and {maximum}.")); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct EffectiveGenerationSettings { + pub(crate) context_tokens: i32, + pub(crate) max_generated_tokens: i32, + pub(crate) system_prompt: String, + pub(crate) temperature: f32, + pub(crate) top_p: f32, + pub(crate) min_p: f32, + pub(crate) seed: Option, + pub(crate) reasoning_mode: ReasoningMode, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_settings_preserve_unset_model_defaults() { + let defaults = GenerationPreferences::default(); + let deepseek = defaults.effective(ModelChoice::DeepSeekV4Flash); + assert_eq!( + (deepseek.temperature, deepseek.top_p, deepseek.min_p), + (1.0, 1.0, 0.05) + ); + + let glm = defaults.effective(ModelChoice::Glm52); + assert_eq!((glm.temperature, glm.top_p, glm.min_p), (1.0, 0.95, 0.0)); + + let explicit = GenerationPreferences { + top_p: Some(0.4), + min_p: Some(0.2), + reasoning_mode: ReasoningMode::Max, + ..defaults + }; + let effective = explicit.effective(ModelChoice::Glm52); + assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2)); + assert_eq!(effective.reasoning_mode, ReasoningMode::High); + } +}