diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fee1409..826d8bf 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -14,9 +14,10 @@ agent-extension data stay on this Mac. and chat data. 3. Choose **File > New Chat** (`⌘N`). Select a thinking profile and a shell permission mode in the composer. -4. Enter a request and send it. **Thinking** is the DS4 default, **Direct** omits - hidden reasoning, and **Think Max** appears only when that model profile has - the required long context. +4. Enter a request and send it. The choices match the selected model: DeepSeek + offers **Direct**, **Think Low**, **Think High**, and **Think Max**; GLM 5.2 + offers **Direct**, **Think High**, and **Think Max**; GLM 5.3 Flash always + thinks and offers **Think Low**, **Think High**, and **Think Max**. The selected model is shared by the app and the optional local HTTP endpoint. Preferences are saved locally and generation settings are scoped by model and @@ -219,6 +220,13 @@ client and do not appear in the sidebar. CORS is off by default; enable it only for trusted browser clients on this Mac. Saving changed endpoint settings restarts the local listener. +`reasoning_effort` accepts only the selected model's advertised values from +`GET /v1/models`: DeepSeek accepts `none`, `low`, `high`, or `max`; GLM 5.2 +accepts `none`, `high`, or `max`; GLM 5.3 Flash accepts `low`, `high`, or `max`. +Omitting it uses the upstream model default: `low` for DeepSeek and `max` for +both GLM models. Unsupported values and conflicting thinking controls return a +400 error instead of being converted to another effort. + ### Data and recovery Application data is under diff --git a/src/app.rs b/src/app.rs index 3f15cdd..decc1ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1737,15 +1737,14 @@ impl App { .supported_reasoning_modes(self.config.model) .contains(&mode) { - self.error = Some( - "Think Max requires a context window of at least 393216 tokens.".into(), - ); + self.error = Some(format!("{mode} is not supported by {}.", self.config.model)); } else { let mut config = self.config.clone(); + let model = config.model; config .model_profiles - .entry(config.model) - .or_default() + .entry(model) + .or_insert_with(|| ModelPreferences::defaults_for(model)) .reasoning_mode = mode; match config.save(&config_path()) { Ok(()) => { diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 53f4549..6d9e34b 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -196,6 +196,7 @@ impl PreferenceDraft { } fn select_generation(&mut self, model: ModelChoice, mode: ReasoningMode) -> Result<(), String> { + model.validate_reasoning_mode(mode)?; self.store_generation()?; self.load_generation(model, mode); Ok(()) @@ -245,14 +246,18 @@ impl PreferenceDraft { let profile = self .model_profiles .entry(self.acceleration_model) - .or_default(); + .or_insert_with(|| ModelPreferences::defaults_for(self.acceleration_model)); 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 profile = self + .model_profiles + .get(&model) + .cloned() + .unwrap_or_else(|| ModelPreferences::defaults_for(model)); let speculative = profile.speculative; let ssd = profile.ssd; self.acceleration_model = model; @@ -301,9 +306,10 @@ impl PreferenceDraft { } pub(super) fn reasoning_mode_for(&self, model: ModelChoice) -> ReasoningMode { - self.model_profiles - .get(&model) - .map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) + self.model_profiles.get(&model).map_or_else( + || model.default_reasoning_mode(), + |profile| profile.reasoning_mode, + ) } pub(super) fn runtime(&self) -> Result { @@ -518,7 +524,7 @@ impl App { self.preference_draft .model_profiles .entry(self.preference_draft.model) - .or_default() + .or_insert_with(|| ModelPreferences::defaults_for(self.preference_draft.model)) .reasoning_mode = self.preference_draft.default_reasoning_mode; let config = Config { model: self.preference_draft.model, @@ -684,7 +690,7 @@ impl App { self.preference_draft .model_profiles .entry(self.preference_draft.model) - .or_default() + .or_insert_with(|| ModelPreferences::defaults_for(self.preference_draft.model)) .reasoning_mode = mode; self.preference_error = None; } @@ -1016,7 +1022,7 @@ mod tests { assert_eq!(draft.context_tokens, "32768"); draft.context_tokens = "456".into(); draft - .select_generation(ModelChoice::DeepSeekV4Flash0731, ReasoningMode::High) + .select_generation(ModelChoice::DeepSeekV4Flash0731, ReasoningMode::Low) .unwrap(); assert_eq!(draft.context_tokens, "123"); diff --git a/src/app/view.rs b/src/app/view.rs index 48dd0ac..96df342 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -19,7 +19,7 @@ use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, }; -use crate::settings::{GIB, REASONING_MODES}; +use crate::settings::GIB; use iced::theme::{Palette, palette}; use iced::widget::{ Button, Space, Svg, Tooltip, button, column, container, image, markdown, mouse_area, opaque, diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index dc603ce..6ca4978 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -114,7 +114,7 @@ impl App { "Thinking mode selected when this model becomes active. Chat can switch it for later turns.", ), pick_list( - &REASONING_MODES[..], + self.preference_draft.model.reasoning_modes(), Some(self.preference_draft.default_reasoning_mode), Message::PreferenceDefaultReasoningChanged, ) @@ -462,7 +462,7 @@ impl App { row![ text("Thinking mode").size(13).width(Length::Fill), pick_list( - &REASONING_MODES[..], + self.preference_draft.generation_model.reasoning_modes(), Some(self.preference_draft.generation_reasoning_mode), Message::PreferenceGenerationReasoningChanged, ) @@ -513,7 +513,7 @@ impl App { text_input("Random", &self.preference_draft.seed) .on_input(Message::PreferenceSeedChanged), ), - text("Blank sampling values retain DS4's model-family defaults. Think Max needs at least 393216 context tokens.") + text("Blank sampling values retain the model-family defaults.") .size(12), text(turn.map_or_else( || "Effective settings will appear after valid values are entered.".to_owned(), diff --git a/src/config.rs b/src/config.rs index 3d85987..2ac975e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,8 @@ use std::path::Path; use crate::model::{MODEL_CHOICES, ModelChoice}; use crate::settings::{ - DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode, - RuntimePreferences, SpeculativePreferences, SsdPreferences, + DEFAULT_SYSTEM_PROMPT, GenerationPreferences, ReasoningMode, RuntimePreferences, + SpeculativePreferences, SsdPreferences, }; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] @@ -20,10 +20,15 @@ pub(crate) struct ModelPreferences { } impl ModelPreferences { - fn defaults_for(model: ModelChoice) -> Self { - let mut preferences = Self::default(); - preferences.speculative.dspark_enabled = model == ModelChoice::DeepSeekV4Flash0731; - preferences + pub(crate) fn defaults_for(model: ModelChoice) -> Self { + Self { + reasoning_mode: model.default_reasoning_mode(), + speculative: SpeculativePreferences { + dspark_enabled: model == ModelChoice::DeepSeekV4Flash0731, + ..SpeculativePreferences::default() + }, + ssd: SsdPreferences::default(), + } } } @@ -63,8 +68,10 @@ impl Default for Config { .map(|model| { ( model, - REASONING_MODES - .into_iter() + model + .reasoning_modes() + .iter() + .copied() .map(|mode| (mode, GenerationPreferences::default())) .collect(), ) @@ -302,9 +309,20 @@ impl Config { drop_legacy_model_settings(&mut value); let migrated = migrate_deprecated_flash(&mut value); let dspark_explicit = deepseek_0731_dspark_is_explicit(&value); + let explicit_reasoning = + MODEL_CHOICES.map(|model| (model, model_reasoning_mode_is_explicit(&value, model))); let mut config: Self = serde_norway::from_value(value) .map_err(|error| format!("Could not read {}: {error}", path.display()))?; config.fill_profile_defaults(); + for (model, explicit) in explicit_reasoning { + if !explicit { + config + .model_profiles + .get_mut(&model) + .expect("model profile is present") + .reasoning_mode = model.default_reasoning_mode(); + } + } if !dspark_explicit { config .model_profiles @@ -344,6 +362,7 @@ impl Config { } for (model, profiles) in &self.generation_profiles { for (mode, generation) in profiles { + model.validate_reasoning_mode(*mode)?; let mut generation = generation.clone(); generation.system_prompt = self.system_prompt.clone(); generation.reasoning_mode = *mode; @@ -355,24 +374,17 @@ impl Config { 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." - )); - } + model.validate_reasoning_mode(preferences.reasoning_mode)?; } 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) + self.model_profiles.get(&model).map_or_else( + || model.default_reasoning_mode(), + |profile| profile.reasoning_mode, + ) } pub(crate) fn generation_for( @@ -405,8 +417,7 @@ impl Config { } pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] { - let max = self.generation_for(model, ReasoningMode::Max); - max.supported_reasoning_modes() + model.reasoning_modes() } fn fill_profile_defaults(&mut self) { @@ -415,13 +426,24 @@ impl Config { .entry(model) .or_insert_with(|| ModelPreferences::defaults_for(model)); let profiles = self.generation_profiles.entry(model).or_default(); - for mode in REASONING_MODES { + for &mode in model.reasoning_modes() { profiles.entry(mode).or_default(); } } } } +fn model_reasoning_mode_is_explicit(value: &Value, model: ModelChoice) -> bool { + let Value::Mapping(root) = value else { + return false; + }; + root.get(Value::String("model_profiles".into())) + .and_then(Value::as_mapping) + .and_then(|profiles| profiles.get(Value::String(model.id().into()))) + .and_then(Value::as_mapping) + .is_some_and(|profile| profile.contains_key(Value::String("reasoning_mode".into()))) +} + fn deepseek_0731_dspark_is_explicit(value: &Value) -> bool { let Value::Mapping(root) = value else { return false; @@ -529,6 +551,34 @@ mod tests { fs::remove_dir_all(&directory).unwrap(); } + #[test] + fn model_profiles_expose_only_their_upstream_thinking_modes() { + let config = Config::default(); + for model in MODEL_CHOICES { + assert_eq!(config.reasoning_mode(model), model.default_reasoning_mode()); + assert_eq!( + config.supported_reasoning_modes(model), + model.reasoning_modes() + ); + let profiles = config.generation_profiles.get(&model).unwrap(); + assert_eq!(profiles.len(), model.reasoning_modes().len()); + assert!( + model + .reasoning_modes() + .iter() + .all(|mode| profiles.contains_key(mode)) + ); + } + + let mut invalid = config; + invalid + .model_profiles + .get_mut(&ModelChoice::Glm53Flash) + .unwrap() + .reasoning_mode = ReasoningMode::Direct; + assert!(invalid.validate().is_err()); + } + #[test] fn explicit_0731_dspark_opt_out_survives_the_default() { let directory = diff --git a/src/engine.rs b/src/engine.rs index e11e406..8b7b9e8 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1690,12 +1690,13 @@ fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn output.extend_from_slice(value.as_bytes()); } - let mut output = b"DS4Server chat checkpoint v4".to_vec(); + let mut output = b"DS4Server chat checkpoint v5".to_vec(); text(&mut output, system); output.push(match reasoning { ReasoningMode::Direct => 0, - ReasoningMode::High => 1, - ReasoningMode::Max => 2, + ReasoningMode::Low => 1, + ReasoningMode::High => 2, + ReasoningMode::Max => 3, }); for message in messages { output.push(u8::from(message.user)); diff --git a/src/engine/tokenizer.rs b/src/engine/tokenizer.rs index 7ddf705..ab3d942 100644 --- a/src/engine/tokenizer.rs +++ b/src/engine/tokenizer.rs @@ -6,9 +6,23 @@ use super::{ use crate::settings::ReasoningMode; use std::collections::HashMap; -const MAX_REASONING_PREFIX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n\ +const HIGH_REASONING_PREFIX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n\ You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n\ Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"; +const MAX_REASONING_PREFIX: &str = "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n\ +You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n\ +Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"; + +fn reasoning_prefix(family: ModelFamily, reasoning: ReasoningMode) -> Option<&'static str> { + match (family, reasoning) { + (ModelFamily::DeepSeek, ReasoningMode::High) => Some(HIGH_REASONING_PREFIX), + (ModelFamily::DeepSeek, ReasoningMode::Max) => Some(MAX_REASONING_PREFIX), + (ModelFamily::Glm, ReasoningMode::Low) => Some("Reasoning Effort: Low"), + (ModelFamily::Glm, ReasoningMode::High) => Some("Reasoning Effort: High"), + (ModelFamily::Glm, ReasoningMode::Max) => Some("Reasoning Effort: Max"), + (_, ReasoningMode::Direct | ReasoningMode::Low) => None, + } +} pub(super) struct Tokenizer { family: ModelFamily, @@ -254,19 +268,11 @@ impl Tokenizer { if self.family == ModelFamily::Glm && self.sop >= 0 { output.push(self.sop); } - match (self.family, reasoning) { - (ModelFamily::Glm, ReasoningMode::High | ReasoningMode::Max) => { + if let Some(prefix) = reasoning_prefix(self.family, reasoning) { + if self.family == ModelFamily::Glm { output.push(self.system); - output.extend(self.tokenize(if reasoning == ReasoningMode::Max { - "Reasoning Effort: Max" - } else { - "Reasoning Effort: High" - })); } - (ModelFamily::DeepSeek, ReasoningMode::Max) => { - output.extend(self.tokenize(MAX_REASONING_PREFIX)); - } - _ => {} + output.extend(self.tokenize(prefix)); } if !system_prompt.is_empty() { if self.family == ModelFamily::Glm { @@ -843,4 +849,32 @@ mod tests { assert_eq!(next_char("", 0), 0); assert!(!cjk_at("", 0)); } + + #[test] + fn reasoning_prefixes_match_each_model_family_contract() { + assert_eq!( + reasoning_prefix(ModelFamily::DeepSeek, ReasoningMode::Low), + None + ); + assert_eq!( + reasoning_prefix(ModelFamily::DeepSeek, ReasoningMode::High), + Some(HIGH_REASONING_PREFIX) + ); + assert_eq!( + reasoning_prefix(ModelFamily::DeepSeek, ReasoningMode::Max), + Some(MAX_REASONING_PREFIX) + ); + assert_eq!( + reasoning_prefix(ModelFamily::Glm, ReasoningMode::Low), + Some("Reasoning Effort: Low") + ); + assert_eq!( + reasoning_prefix(ModelFamily::Glm, ReasoningMode::High), + Some("Reasoning Effort: High") + ); + assert_eq!( + reasoning_prefix(ModelFamily::Glm, ReasoningMode::Max), + Some("Reasoning Effort: Max") + ); + } } diff --git a/src/server.rs b/src/server.rs index e110667..5f31c67 100644 --- a/src/server.rs +++ b/src/server.rs @@ -540,7 +540,12 @@ fn model_json(id: &str, model: ModelChoice, context: i32, default_tokens: i32) - "supported_parameters": [ "tools", "tool_choice", "max_tokens", "temperature", "top_p", "top_k", "min_p", "stop", "seed", "stream", "reasoning_effort" - ] + ], + "supported_reasoning_efforts": model + .reasoning_modes() + .iter() + .map(|mode| mode.as_id()) + .collect::>() }) } @@ -555,10 +560,8 @@ fn model_alias(id: &str) -> Option { | "zai/glm-5.2-chat" | "zai/glm-5.2-reasoner" => Some(ModelChoice::Glm52), "glm-5.3-flash" - | "glm-5.3-flash-chat" | "glm-5.3-flash-reasoner" | "zai/glm-5.3-flash" - | "zai/glm-5.3-flash-chat" | "zai/glm-5.3-flash-reasoner" => Some(ModelChoice::Glm53Flash), _ => ModelChoice::from_id(id), } @@ -1023,6 +1026,7 @@ mod tests { model_alias("deepseek-reasoner"), Some(ModelChoice::DeepSeekV4Flash0731) ); + assert_eq!(model_alias("glm-5.3-flash-chat"), None); let model = model_json( "deepseek-reasoner", ModelChoice::DeepSeekV4Flash0731, @@ -1031,6 +1035,18 @@ mod tests { ); assert_eq!(model["id"], "deepseek-reasoner"); assert_eq!(model["top_provider"]["max_completion_tokens"], 32_768); + assert_eq!( + model["supported_reasoning_efforts"], + json!(["low", "high", "max", "none"]) + ); + assert_eq!( + model_json("glm-5.2", ModelChoice::Glm52, 32_768, 50_000)["supported_reasoning_efforts"], + json!(["high", "max", "none"]) + ); + assert_eq!( + model_json("glm-5.3-flash", ModelChoice::Glm53Flash, 32_768, 50_000)["supported_reasoning_efforts"], + json!(["low", "high", "max"]) + ); } #[test] diff --git a/src/server/request.rs b/src/server/request.rs index dfe783c..6c86eea 100644 --- a/src/server/request.rs +++ b/src/server/request.rs @@ -534,7 +534,7 @@ pub(super) fn parse_chat_request( return Err((400, format!("model is not installed and verified: {model}"))); } - let reasoning_mode = request_reasoning(&request, requested_id)?; + let reasoning_mode = request_reasoning(&request, model, requested_id)?; let mut generation = config.generation_for(model, reasoning_mode); generation.max_generated_tokens = request .max_completion_tokens @@ -594,31 +594,43 @@ pub(super) fn parse_chat_request( fn request_reasoning( request: &ChatRequest, + model: ModelChoice, model_id: &str, ) -> Result { let explicit_thinking = request .think .or_else(|| request.thinking.as_ref().and_then(thinking_enabled)); - let mut reasoning = match request.reasoning_effort.as_deref() { - Some("max") => ReasoningMode::Max, - Some("none") => ReasoningMode::Direct, - Some("xhigh" | "high" | "medium" | "low" | "minimal") | None => ReasoningMode::High, + let explicit_effort = match request.reasoning_effort.as_deref() { + Some("none") => Some(ReasoningMode::Direct), + Some("low") => Some(ReasoningMode::Low), + Some("high") => Some(ReasoningMode::High), + Some("max") => Some(ReasoningMode::Max), + None => None, Some(value) => return Err((400, format!("unsupported reasoning_effort: {value}"))), }; - if explicit_thinking == Some(false) - || (explicit_thinking.is_none() - && matches!( - model_id, - "deepseek-chat" - | "glm-5.2-chat" - | "glm-5.2-no-think" - | "glm-5.2-nothink" - | "glm-5.3-flash-chat" - | "zai/glm-5.3-flash-chat" - )) + let direct_alias = matches!( + model_id, + "deepseek-chat" + | "glm-5.2-chat" + | "glm-5.2-no-think" + | "glm-5.2-nothink" + | "zai/glm-5.2-chat" + ); + if (direct_alias && explicit_thinking == Some(true)) + || ((direct_alias || explicit_thinking == Some(false)) + && explicit_effort.is_some_and(|mode| mode != ReasoningMode::Direct)) + || (explicit_thinking == Some(true) && explicit_effort == Some(ReasoningMode::Direct)) { - reasoning = ReasoningMode::Direct; + return Err((400, "conflicting thinking controls".into())); } + let reasoning = if direct_alias || explicit_thinking == Some(false) { + ReasoningMode::Direct + } else { + explicit_effort.unwrap_or_else(|| model.default_reasoning_mode()) + }; + model + .validate_reasoning_mode(reasoning) + .map_err(|error| (400, error))?; Ok(reasoning) } @@ -632,3 +644,70 @@ fn thinking_enabled(value: &Value) -> Option { }) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn request(effort: Option<&str>, thinking: Option) -> ChatRequest { + let mut value = json!({"messages": [{"role": "user", "content": "hello"}]}); + if let Some(effort) = effort { + value["reasoning_effort"] = Value::String(effort.into()); + } + if let Some(thinking) = thinking { + value["thinking"] = Value::Bool(thinking); + } + decode_chat_request(value).unwrap() + } + + #[test] + fn reasoning_requests_preserve_each_models_exact_modes() { + let default = request(None, None); + assert_eq!( + request_reasoning(&default, ModelChoice::DeepSeekV4Flash0731, "").unwrap(), + ReasoningMode::Low + ); + assert_eq!( + request_reasoning(&default, ModelChoice::DeepSeekV4Pro, "").unwrap(), + ReasoningMode::Low + ); + assert_eq!( + request_reasoning(&default, ModelChoice::Glm52, "").unwrap(), + ReasoningMode::Max + ); + assert_eq!( + request_reasoning(&default, ModelChoice::Glm53Flash, "").unwrap(), + ReasoningMode::Max + ); + + for (effort, mode) in [ + ("none", ReasoningMode::Direct), + ("low", ReasoningMode::Low), + ("high", ReasoningMode::High), + ("max", ReasoningMode::Max), + ] { + let explicit = request(Some(effort), None); + for model in crate::model::MODEL_CHOICES { + let result = request_reasoning(&explicit, model, ""); + if model.reasoning_modes().contains(&mode) { + assert_eq!(result.unwrap(), mode); + } else { + assert!(result.is_err()); + } + } + } + + assert!(request_reasoning(&request(Some("medium"), None), ModelChoice::Glm52, "").is_err()); + assert!( + request_reasoning(&request(None, Some(false)), ModelChoice::Glm53Flash, "").is_err() + ); + assert!( + request_reasoning( + &request(None, Some(true)), + ModelChoice::DeepSeekV4Flash0731, + "deepseek-chat" + ) + .is_err() + ); + } +} diff --git a/src/settings.rs b/src/settings.rs index c5f7a20..d98f370 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,13 +3,19 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::path::Path; -pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [ +const DEEPSEEK_REASONING_MODES: [ReasoningMode; 4] = [ + ReasoningMode::Low, ReasoningMode::High, ReasoningMode::Max, ReasoningMode::Direct, ]; -const STANDARD_REASONING_MODES: [ReasoningMode; 2] = [ReasoningMode::High, ReasoningMode::Direct]; -const THINK_MAX_MIN_CONTEXT: i32 = 393_216; +const GLM_52_REASONING_MODES: [ReasoningMode; 3] = [ + ReasoningMode::High, + ReasoningMode::Max, + ReasoningMode::Direct, +]; +const GLM_53_REASONING_MODES: [ReasoningMode; 3] = + [ReasoningMode::Low, ReasoningMode::High, ReasoningMode::Max]; const MAX_CPU_THREADS: u32 = 32; pub(crate) const GIB: u64 = 1024 * 1024 * 1024; /// DS4 disk KV cache defaults, from `ds4_kvstore.h` and `--kv-disk-space-mb`. @@ -486,6 +492,7 @@ pub(crate) enum ReasoningMode { #[serde(rename = "none")] Direct, #[default] + Low, High, Max, } @@ -494,12 +501,49 @@ 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::Low => "Think Low", + Self::High => "Think High", Self::Max => "Think Max", }) } } +impl ReasoningMode { + pub(crate) fn as_id(self) -> &'static str { + match self { + Self::Direct => "none", + Self::Low => "low", + Self::High => "high", + Self::Max => "max", + } + } +} + +impl ModelChoice { + pub(crate) fn reasoning_modes(self) -> &'static [ReasoningMode] { + match self { + Self::DeepSeekV4Flash0731 | Self::DeepSeekV4Pro => &DEEPSEEK_REASONING_MODES, + Self::Glm52 => &GLM_52_REASONING_MODES, + Self::Glm53Flash => &GLM_53_REASONING_MODES, + } + } + + pub(crate) fn default_reasoning_mode(self) -> ReasoningMode { + match self { + Self::DeepSeekV4Flash0731 | Self::DeepSeekV4Pro => ReasoningMode::Low, + Self::Glm52 | Self::Glm53Flash => ReasoningMode::Max, + } + } + + pub(crate) fn validate_reasoning_mode(self, mode: ReasoningMode) -> Result<(), String> { + if self.reasoning_modes().contains(&mode) { + Ok(()) + } else { + Err(format!("{mode} is not supported by {self}.")) + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct GenerationPreferences { @@ -531,29 +575,12 @@ impl Default for GenerationPreferences { top_p: None, min_p: None, seed: None, - reasoning_mode: ReasoningMode::High, + reasoning_mode: ReasoningMode::Low, } } } impl GenerationPreferences { - pub(crate) fn effective_reasoning_mode(&self) -> ReasoningMode { - if self.reasoning_mode == ReasoningMode::Max && self.context_tokens < THINK_MAX_MIN_CONTEXT - { - ReasoningMode::High - } else { - self.reasoning_mode - } - } - - pub(crate) fn supported_reasoning_modes(&self) -> &'static [ReasoningMode] { - if self.context_tokens >= THINK_MAX_MIN_CONTEXT { - &REASONING_MODES - } else { - &STANDARD_REASONING_MODES - } - } - pub(crate) fn validate(&self) -> Result<(), String> { if self.context_tokens <= 0 { return Err("Context tokens must be a positive whole number.".into()); @@ -587,7 +614,7 @@ impl GenerationPreferences { top_k: 0, stops: Vec::new(), seed: self.seed, - reasoning_mode: self.effective_reasoning_mode(), + reasoning_mode: self.reasoning_mode, } } } @@ -648,6 +675,7 @@ pub(crate) fn effective_settings( models_path: &Path, ) -> Result { generation.validate()?; + model.validate_reasoning_mode(generation.reasoning_mode)?; Ok(EffectiveSettings { engine: runtime.engine_settings(model, generation.context_tokens, models_path)?, turn: generation.turn_settings(model, runtime.kv_cache.settings()), @@ -673,11 +701,7 @@ mod tests { let glm = defaults.turn_settings(ModelChoice::Glm52, cache); assert_eq!((glm.temperature, glm.top_p, glm.min_p), (1.0, 0.95, 0.0)); - assert_eq!( - defaults.supported_reasoning_modes(), - &[ReasoningMode::High, ReasoningMode::Direct] - ); - assert_eq!(defaults.effective_reasoning_mode(), ReasoningMode::High); + assert_eq!(defaults.reasoning_mode, ReasoningMode::Low); let explicit = GenerationPreferences { top_p: Some(0.4), @@ -687,39 +711,33 @@ mod tests { }; let effective = explicit.turn_settings(ModelChoice::Glm52, cache); assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2)); - assert_eq!(effective.reasoning_mode, ReasoningMode::High); - - let long_context = GenerationPreferences { - context_tokens: THINK_MAX_MIN_CONTEXT, - reasoning_mode: ReasoningMode::Max, - ..GenerationPreferences::default() - }; - assert_eq!(long_context.supported_reasoning_modes(), &REASONING_MODES); - assert_eq!(long_context.effective_reasoning_mode(), ReasoningMode::Max); + assert_eq!(effective.reasoning_mode, ReasoningMode::Max); } #[test] - fn reasoning_selection_reaches_every_model_turn() { + fn reasoning_selection_is_exact_and_model_specific() { let cache = KvCachePreferences::default().settings(); for model in crate::model::MODEL_CHOICES { - for mode in [ReasoningMode::Direct, ReasoningMode::High] { + for &mode in model.reasoning_modes() { let generation = GenerationPreferences { reasoning_mode: mode, ..GenerationPreferences::default() }; assert_eq!(generation.turn_settings(model, cache).reasoning_mode, mode); + assert!(model.validate_reasoning_mode(mode).is_ok()); } - - let generation = GenerationPreferences { - context_tokens: THINK_MAX_MIN_CONTEXT, - reasoning_mode: ReasoningMode::Max, - ..GenerationPreferences::default() - }; - assert_eq!( - generation.turn_settings(model, cache).reasoning_mode, - ReasoningMode::Max - ); } + + assert!( + ModelChoice::Glm52 + .validate_reasoning_mode(ReasoningMode::Low) + .is_err() + ); + assert!( + ModelChoice::Glm53Flash + .validate_reasoning_mode(ReasoningMode::Direct) + .is_err() + ); } #[test]