correct thinking modes for each model

This commit is contained in:
Georg Bauer
2026-09-01 21:03:13 +02:00
parent ee515ee824
commit d40e86e5ef
11 changed files with 338 additions and 127 deletions

View File

@@ -14,9 +14,10 @@ agent-extension data stay on this Mac.
and chat data. and chat data.
3. Choose **File > New Chat** (`⌘N`). Select a thinking profile and a shell 3. Choose **File > New Chat** (`⌘N`). Select a thinking profile and a shell
permission mode in the composer. permission mode in the composer.
4. Enter a request and send it. **Thinking** is the DS4 default, **Direct** omits 4. Enter a request and send it. The choices match the selected model: DeepSeek
hidden reasoning, and **Think Max** appears only when that model profile has offers **Direct**, **Think Low**, **Think High**, and **Think Max**; GLM 5.2
the required long context. 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. 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 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 for trusted browser clients on this Mac. Saving changed endpoint settings
restarts the local listener. 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 ### Data and recovery
Application data is under Application data is under

View File

@@ -1737,15 +1737,14 @@ impl App {
.supported_reasoning_modes(self.config.model) .supported_reasoning_modes(self.config.model)
.contains(&mode) .contains(&mode)
{ {
self.error = Some( self.error = Some(format!("{mode} is not supported by {}.", self.config.model));
"Think Max requires a context window of at least 393216 tokens.".into(),
);
} else { } else {
let mut config = self.config.clone(); let mut config = self.config.clone();
let model = config.model;
config config
.model_profiles .model_profiles
.entry(config.model) .entry(model)
.or_default() .or_insert_with(|| ModelPreferences::defaults_for(model))
.reasoning_mode = mode; .reasoning_mode = mode;
match config.save(&config_path()) { match config.save(&config_path()) {
Ok(()) => { Ok(()) => {

View File

@@ -196,6 +196,7 @@ impl PreferenceDraft {
} }
fn select_generation(&mut self, model: ModelChoice, mode: ReasoningMode) -> Result<(), String> { fn select_generation(&mut self, model: ModelChoice, mode: ReasoningMode) -> Result<(), String> {
model.validate_reasoning_mode(mode)?;
self.store_generation()?; self.store_generation()?;
self.load_generation(model, mode); self.load_generation(model, mode);
Ok(()) Ok(())
@@ -245,14 +246,18 @@ impl PreferenceDraft {
let profile = self let profile = self
.model_profiles .model_profiles
.entry(self.acceleration_model) .entry(self.acceleration_model)
.or_default(); .or_insert_with(|| ModelPreferences::defaults_for(self.acceleration_model));
profile.speculative = speculative; profile.speculative = speculative;
profile.ssd = ssd; profile.ssd = ssd;
Ok(()) Ok(())
} }
fn load_acceleration(&mut self, model: ModelChoice) { 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 speculative = profile.speculative;
let ssd = profile.ssd; let ssd = profile.ssd;
self.acceleration_model = model; self.acceleration_model = model;
@@ -301,9 +306,10 @@ impl PreferenceDraft {
} }
pub(super) fn reasoning_mode_for(&self, model: ModelChoice) -> ReasoningMode { pub(super) fn reasoning_mode_for(&self, model: ModelChoice) -> ReasoningMode {
self.model_profiles self.model_profiles.get(&model).map_or_else(
.get(&model) || model.default_reasoning_mode(),
.map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) |profile| profile.reasoning_mode,
)
} }
pub(super) fn runtime(&self) -> Result<RuntimePreferences, String> { pub(super) fn runtime(&self) -> Result<RuntimePreferences, String> {
@@ -518,7 +524,7 @@ impl App {
self.preference_draft self.preference_draft
.model_profiles .model_profiles
.entry(self.preference_draft.model) .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; .reasoning_mode = self.preference_draft.default_reasoning_mode;
let config = Config { let config = Config {
model: self.preference_draft.model, model: self.preference_draft.model,
@@ -684,7 +690,7 @@ impl App {
self.preference_draft self.preference_draft
.model_profiles .model_profiles
.entry(self.preference_draft.model) .entry(self.preference_draft.model)
.or_default() .or_insert_with(|| ModelPreferences::defaults_for(self.preference_draft.model))
.reasoning_mode = mode; .reasoning_mode = mode;
self.preference_error = None; self.preference_error = None;
} }
@@ -1016,7 +1022,7 @@ mod tests {
assert_eq!(draft.context_tokens, "32768"); assert_eq!(draft.context_tokens, "32768");
draft.context_tokens = "456".into(); draft.context_tokens = "456".into();
draft draft
.select_generation(ModelChoice::DeepSeekV4Flash0731, ReasoningMode::High) .select_generation(ModelChoice::DeepSeekV4Flash0731, ReasoningMode::Low)
.unwrap(); .unwrap();
assert_eq!(draft.context_tokens, "123"); assert_eq!(draft.context_tokens, "123");

View File

@@ -19,7 +19,7 @@ use crate::database::{ProjectWithSessions, Session, SessionState};
use crate::model::{ use crate::model::{
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
}; };
use crate::settings::{GIB, REASONING_MODES}; use crate::settings::GIB;
use iced::theme::{Palette, palette}; use iced::theme::{Palette, palette};
use iced::widget::{ use iced::widget::{
Button, Space, Svg, Tooltip, button, column, container, image, markdown, mouse_area, opaque, Button, Space, Svg, Tooltip, button, column, container, image, markdown, mouse_area, opaque,

View File

@@ -114,7 +114,7 @@ impl App {
"Thinking mode selected when this model becomes active. Chat can switch it for later turns.", "Thinking mode selected when this model becomes active. Chat can switch it for later turns.",
), ),
pick_list( pick_list(
&REASONING_MODES[..], self.preference_draft.model.reasoning_modes(),
Some(self.preference_draft.default_reasoning_mode), Some(self.preference_draft.default_reasoning_mode),
Message::PreferenceDefaultReasoningChanged, Message::PreferenceDefaultReasoningChanged,
) )
@@ -462,7 +462,7 @@ impl App {
row![ row![
text("Thinking mode").size(13).width(Length::Fill), text("Thinking mode").size(13).width(Length::Fill),
pick_list( pick_list(
&REASONING_MODES[..], self.preference_draft.generation_model.reasoning_modes(),
Some(self.preference_draft.generation_reasoning_mode), Some(self.preference_draft.generation_reasoning_mode),
Message::PreferenceGenerationReasoningChanged, Message::PreferenceGenerationReasoningChanged,
) )
@@ -513,7 +513,7 @@ impl App {
text_input("Random", &self.preference_draft.seed) text_input("Random", &self.preference_draft.seed)
.on_input(Message::PreferenceSeedChanged), .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), .size(12),
text(turn.map_or_else( text(turn.map_or_else(
|| "Effective settings will appear after valid values are entered.".to_owned(), || "Effective settings will appear after valid values are entered.".to_owned(),

View File

@@ -7,8 +7,8 @@ use std::path::Path;
use crate::model::{MODEL_CHOICES, ModelChoice}; use crate::model::{MODEL_CHOICES, ModelChoice};
use crate::settings::{ use crate::settings::{
DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode, DEFAULT_SYSTEM_PROMPT, GenerationPreferences, ReasoningMode, RuntimePreferences,
RuntimePreferences, SpeculativePreferences, SsdPreferences, SpeculativePreferences, SsdPreferences,
}; };
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
@@ -20,10 +20,15 @@ pub(crate) struct ModelPreferences {
} }
impl ModelPreferences { impl ModelPreferences {
fn defaults_for(model: ModelChoice) -> Self { pub(crate) fn defaults_for(model: ModelChoice) -> Self {
let mut preferences = Self::default(); Self {
preferences.speculative.dspark_enabled = model == ModelChoice::DeepSeekV4Flash0731; reasoning_mode: model.default_reasoning_mode(),
preferences speculative: SpeculativePreferences {
dspark_enabled: model == ModelChoice::DeepSeekV4Flash0731,
..SpeculativePreferences::default()
},
ssd: SsdPreferences::default(),
}
} }
} }
@@ -63,8 +68,10 @@ impl Default for Config {
.map(|model| { .map(|model| {
( (
model, model,
REASONING_MODES model
.into_iter() .reasoning_modes()
.iter()
.copied()
.map(|mode| (mode, GenerationPreferences::default())) .map(|mode| (mode, GenerationPreferences::default()))
.collect(), .collect(),
) )
@@ -302,9 +309,20 @@ impl Config {
drop_legacy_model_settings(&mut value); drop_legacy_model_settings(&mut value);
let migrated = migrate_deprecated_flash(&mut value); let migrated = migrate_deprecated_flash(&mut value);
let dspark_explicit = deepseek_0731_dspark_is_explicit(&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) let mut config: Self = serde_norway::from_value(value)
.map_err(|error| format!("Could not read {}: {error}", path.display()))?; .map_err(|error| format!("Could not read {}: {error}", path.display()))?;
config.fill_profile_defaults(); 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 { if !dspark_explicit {
config config
.model_profiles .model_profiles
@@ -344,6 +362,7 @@ impl Config {
} }
for (model, profiles) in &self.generation_profiles { for (model, profiles) in &self.generation_profiles {
for (mode, generation) in profiles { for (mode, generation) in profiles {
model.validate_reasoning_mode(*mode)?;
let mut generation = generation.clone(); let mut generation = generation.clone();
generation.system_prompt = self.system_prompt.clone(); generation.system_prompt = self.system_prompt.clone();
generation.reasoning_mode = *mode; generation.reasoning_mode = *mode;
@@ -355,24 +374,17 @@ impl Config {
for (model, preferences) in &self.model_profiles { for (model, preferences) in &self.model_profiles {
preferences.speculative.validate(*model)?; preferences.speculative.validate(*model)?;
preferences.ssd.validate(*model)?; preferences.ssd.validate(*model)?;
if preferences.reasoning_mode == ReasoningMode::Max model.validate_reasoning_mode(preferences.reasoning_mode)?;
&& !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.runtime_for(self.model).validate(self.model)?;
self.dev_brain.validate() self.dev_brain.validate()
} }
pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode { pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode {
self.model_profiles self.model_profiles.get(&model).map_or_else(
.get(&model) || model.default_reasoning_mode(),
.map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) |profile| profile.reasoning_mode,
)
} }
pub(crate) fn generation_for( pub(crate) fn generation_for(
@@ -405,8 +417,7 @@ impl Config {
} }
pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] { pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] {
let max = self.generation_for(model, ReasoningMode::Max); model.reasoning_modes()
max.supported_reasoning_modes()
} }
fn fill_profile_defaults(&mut self) { fn fill_profile_defaults(&mut self) {
@@ -415,13 +426,24 @@ impl Config {
.entry(model) .entry(model)
.or_insert_with(|| ModelPreferences::defaults_for(model)); .or_insert_with(|| ModelPreferences::defaults_for(model));
let profiles = self.generation_profiles.entry(model).or_default(); 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(); 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 { fn deepseek_0731_dspark_is_explicit(value: &Value) -> bool {
let Value::Mapping(root) = value else { let Value::Mapping(root) = value else {
return false; return false;
@@ -529,6 +551,34 @@ mod tests {
fs::remove_dir_all(&directory).unwrap(); 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] #[test]
fn explicit_0731_dspark_opt_out_survives_the_default() { fn explicit_0731_dspark_opt_out_survives_the_default() {
let directory = let directory =

View File

@@ -1690,12 +1690,13 @@ fn conversation_key(system: &str, reasoning: ReasoningMode, messages: &[ChatTurn
output.extend_from_slice(value.as_bytes()); 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); text(&mut output, system);
output.push(match reasoning { output.push(match reasoning {
ReasoningMode::Direct => 0, ReasoningMode::Direct => 0,
ReasoningMode::High => 1, ReasoningMode::Low => 1,
ReasoningMode::Max => 2, ReasoningMode::High => 2,
ReasoningMode::Max => 3,
}); });
for message in messages { for message in messages {
output.push(u8::from(message.user)); output.push(u8::from(message.user));

View File

@@ -6,9 +6,23 @@ use super::{
use crate::settings::ReasoningMode; use crate::settings::ReasoningMode;
use std::collections::HashMap; 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\ 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"; 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 { pub(super) struct Tokenizer {
family: ModelFamily, family: ModelFamily,
@@ -254,19 +268,11 @@ impl Tokenizer {
if self.family == ModelFamily::Glm && self.sop >= 0 { if self.family == ModelFamily::Glm && self.sop >= 0 {
output.push(self.sop); output.push(self.sop);
} }
match (self.family, reasoning) { if let Some(prefix) = reasoning_prefix(self.family, reasoning) {
(ModelFamily::Glm, ReasoningMode::High | ReasoningMode::Max) => { if self.family == ModelFamily::Glm {
output.push(self.system); 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(prefix));
output.extend(self.tokenize(MAX_REASONING_PREFIX));
}
_ => {}
} }
if !system_prompt.is_empty() { if !system_prompt.is_empty() {
if self.family == ModelFamily::Glm { if self.family == ModelFamily::Glm {
@@ -843,4 +849,32 @@ mod tests {
assert_eq!(next_char("", 0), 0); assert_eq!(next_char("", 0), 0);
assert!(!cjk_at("", 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")
);
}
} }

View File

@@ -540,7 +540,12 @@ fn model_json(id: &str, model: ModelChoice, context: i32, default_tokens: i32) -
"supported_parameters": [ "supported_parameters": [
"tools", "tool_choice", "max_tokens", "temperature", "top_p", "tools", "tool_choice", "max_tokens", "temperature", "top_p",
"top_k", "min_p", "stop", "seed", "stream", "reasoning_effort" "top_k", "min_p", "stop", "seed", "stream", "reasoning_effort"
] ],
"supported_reasoning_efforts": model
.reasoning_modes()
.iter()
.map(|mode| mode.as_id())
.collect::<Vec<_>>()
}) })
} }
@@ -555,10 +560,8 @@ fn model_alias(id: &str) -> Option<ModelChoice> {
| "zai/glm-5.2-chat" | "zai/glm-5.2-chat"
| "zai/glm-5.2-reasoner" => Some(ModelChoice::Glm52), | "zai/glm-5.2-reasoner" => Some(ModelChoice::Glm52),
"glm-5.3-flash" "glm-5.3-flash"
| "glm-5.3-flash-chat"
| "glm-5.3-flash-reasoner" | "glm-5.3-flash-reasoner"
| "zai/glm-5.3-flash" | "zai/glm-5.3-flash"
| "zai/glm-5.3-flash-chat"
| "zai/glm-5.3-flash-reasoner" => Some(ModelChoice::Glm53Flash), | "zai/glm-5.3-flash-reasoner" => Some(ModelChoice::Glm53Flash),
_ => ModelChoice::from_id(id), _ => ModelChoice::from_id(id),
} }
@@ -1023,6 +1026,7 @@ mod tests {
model_alias("deepseek-reasoner"), model_alias("deepseek-reasoner"),
Some(ModelChoice::DeepSeekV4Flash0731) Some(ModelChoice::DeepSeekV4Flash0731)
); );
assert_eq!(model_alias("glm-5.3-flash-chat"), None);
let model = model_json( let model = model_json(
"deepseek-reasoner", "deepseek-reasoner",
ModelChoice::DeepSeekV4Flash0731, ModelChoice::DeepSeekV4Flash0731,
@@ -1031,6 +1035,18 @@ mod tests {
); );
assert_eq!(model["id"], "deepseek-reasoner"); assert_eq!(model["id"], "deepseek-reasoner");
assert_eq!(model["top_provider"]["max_completion_tokens"], 32_768); 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] #[test]

View File

@@ -534,7 +534,7 @@ pub(super) fn parse_chat_request(
return Err((400, format!("model is not installed and verified: {model}"))); 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); let mut generation = config.generation_for(model, reasoning_mode);
generation.max_generated_tokens = request generation.max_generated_tokens = request
.max_completion_tokens .max_completion_tokens
@@ -594,31 +594,43 @@ pub(super) fn parse_chat_request(
fn request_reasoning( fn request_reasoning(
request: &ChatRequest, request: &ChatRequest,
model: ModelChoice,
model_id: &str, model_id: &str,
) -> Result<ReasoningMode, (u16, String)> { ) -> Result<ReasoningMode, (u16, String)> {
let explicit_thinking = request let explicit_thinking = request
.think .think
.or_else(|| request.thinking.as_ref().and_then(thinking_enabled)); .or_else(|| request.thinking.as_ref().and_then(thinking_enabled));
let mut reasoning = match request.reasoning_effort.as_deref() { let explicit_effort = match request.reasoning_effort.as_deref() {
Some("max") => ReasoningMode::Max, Some("none") => Some(ReasoningMode::Direct),
Some("none") => ReasoningMode::Direct, Some("low") => Some(ReasoningMode::Low),
Some("xhigh" | "high" | "medium" | "low" | "minimal") | None => ReasoningMode::High, Some("high") => Some(ReasoningMode::High),
Some("max") => Some(ReasoningMode::Max),
None => None,
Some(value) => return Err((400, format!("unsupported reasoning_effort: {value}"))), Some(value) => return Err((400, format!("unsupported reasoning_effort: {value}"))),
}; };
if explicit_thinking == Some(false) let direct_alias = matches!(
|| (explicit_thinking.is_none() model_id,
&& matches!( "deepseek-chat"
model_id, | "glm-5.2-chat"
"deepseek-chat" | "glm-5.2-no-think"
| "glm-5.2-chat" | "glm-5.2-nothink"
| "glm-5.2-no-think" | "zai/glm-5.2-chat"
| "glm-5.2-nothink" );
| "glm-5.3-flash-chat" if (direct_alias && explicit_thinking == Some(true))
| "zai/glm-5.3-flash-chat" || ((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) Ok(reasoning)
} }
@@ -632,3 +644,70 @@ fn thinking_enabled(value: &Value) -> Option<bool> {
}) })
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
fn request(effort: Option<&str>, thinking: Option<bool>) -> 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()
);
}
}

View File

@@ -3,13 +3,19 @@ use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::path::Path; use std::path::Path;
pub(crate) const REASONING_MODES: [ReasoningMode; 3] = [ const DEEPSEEK_REASONING_MODES: [ReasoningMode; 4] = [
ReasoningMode::Low,
ReasoningMode::High, ReasoningMode::High,
ReasoningMode::Max, ReasoningMode::Max,
ReasoningMode::Direct, ReasoningMode::Direct,
]; ];
const STANDARD_REASONING_MODES: [ReasoningMode; 2] = [ReasoningMode::High, ReasoningMode::Direct]; const GLM_52_REASONING_MODES: [ReasoningMode; 3] = [
const THINK_MAX_MIN_CONTEXT: i32 = 393_216; 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; const MAX_CPU_THREADS: u32 = 32;
pub(crate) const GIB: u64 = 1024 * 1024 * 1024; pub(crate) const GIB: u64 = 1024 * 1024 * 1024;
/// DS4 disk KV cache defaults, from `ds4_kvstore.h` and `--kv-disk-space-mb`. /// 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")] #[serde(rename = "none")]
Direct, Direct,
#[default] #[default]
Low,
High, High,
Max, Max,
} }
@@ -494,12 +501,49 @@ impl fmt::Display for ReasoningMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self { formatter.write_str(match self {
Self::Direct => "Direct (no thinking)", Self::Direct => "Direct (no thinking)",
Self::High => "Thinking", Self::Low => "Think Low",
Self::High => "Think High",
Self::Max => "Think Max", 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)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(crate) struct GenerationPreferences { pub(crate) struct GenerationPreferences {
@@ -531,29 +575,12 @@ impl Default for GenerationPreferences {
top_p: None, top_p: None,
min_p: None, min_p: None,
seed: None, seed: None,
reasoning_mode: ReasoningMode::High, reasoning_mode: ReasoningMode::Low,
} }
} }
} }
impl GenerationPreferences { 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> { pub(crate) fn validate(&self) -> Result<(), String> {
if self.context_tokens <= 0 { if self.context_tokens <= 0 {
return Err("Context tokens must be a positive whole number.".into()); return Err("Context tokens must be a positive whole number.".into());
@@ -587,7 +614,7 @@ impl GenerationPreferences {
top_k: 0, top_k: 0,
stops: Vec::new(), stops: Vec::new(),
seed: self.seed, 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, models_path: &Path,
) -> Result<EffectiveSettings, String> { ) -> Result<EffectiveSettings, String> {
generation.validate()?; generation.validate()?;
model.validate_reasoning_mode(generation.reasoning_mode)?;
Ok(EffectiveSettings { Ok(EffectiveSettings {
engine: runtime.engine_settings(model, generation.context_tokens, models_path)?, engine: runtime.engine_settings(model, generation.context_tokens, models_path)?,
turn: generation.turn_settings(model, runtime.kv_cache.settings()), turn: generation.turn_settings(model, runtime.kv_cache.settings()),
@@ -673,11 +701,7 @@ mod tests {
let glm = defaults.turn_settings(ModelChoice::Glm52, cache); 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!((glm.temperature, glm.top_p, glm.min_p), (1.0, 0.95, 0.0));
assert_eq!( assert_eq!(defaults.reasoning_mode, ReasoningMode::Low);
defaults.supported_reasoning_modes(),
&[ReasoningMode::High, ReasoningMode::Direct]
);
assert_eq!(defaults.effective_reasoning_mode(), ReasoningMode::High);
let explicit = GenerationPreferences { let explicit = GenerationPreferences {
top_p: Some(0.4), top_p: Some(0.4),
@@ -687,39 +711,33 @@ mod tests {
}; };
let effective = explicit.turn_settings(ModelChoice::Glm52, cache); let effective = explicit.turn_settings(ModelChoice::Glm52, cache);
assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2)); assert_eq!((effective.top_p, effective.min_p), (0.4, 0.2));
assert_eq!(effective.reasoning_mode, ReasoningMode::High); assert_eq!(effective.reasoning_mode, ReasoningMode::Max);
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);
} }
#[test] #[test]
fn reasoning_selection_reaches_every_model_turn() { fn reasoning_selection_is_exact_and_model_specific() {
let cache = KvCachePreferences::default().settings(); let cache = KvCachePreferences::default().settings();
for model in crate::model::MODEL_CHOICES { for model in crate::model::MODEL_CHOICES {
for mode in [ReasoningMode::Direct, ReasoningMode::High] { for &mode in model.reasoning_modes() {
let generation = GenerationPreferences { let generation = GenerationPreferences {
reasoning_mode: mode, reasoning_mode: mode,
..GenerationPreferences::default() ..GenerationPreferences::default()
}; };
assert_eq!(generation.turn_settings(model, cache).reasoning_mode, mode); 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] #[test]