Store preferences by model profile
This commit is contained in:
199
src/config.rs
199
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<ModelChoice, BTreeMap<ReasoningMode, GenerationPreferences>>,
|
||||
pub(crate) model_profiles: BTreeMap<ModelChoice, ModelPreferences>,
|
||||
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<Value> {
|
||||
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::<Mapping>();
|
||||
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()));
|
||||
|
||||
Reference in New Issue
Block a user