1use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE};
7use crate::models::reasoning::ReasoningLevel;
8use crate::prompts;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ModelConfig {
15 pub model: String,
18
19 #[serde(default = "default_temperature")]
21 pub temperature: f32,
22
23 #[serde(default = "default_max_tokens")]
25 pub max_tokens: usize,
26
27 pub system_prompt: Option<String>,
29
30 #[serde(skip)]
37 pub dynamic_system_suffix: Option<String>,
38
39 #[serde(default)]
45 pub reasoning: ReasoningLevel,
46
47 #[serde(default)]
54 pub hide_reasoning_trace: bool,
55
56 #[serde(skip)]
59 pub is_subagent: bool,
60
61 #[serde(default)]
64 pub backend_options: HashMap<String, HashMap<String, String>>,
65
66 #[serde(skip)]
69 pub mcp_tools: Vec<serde_json::Value>,
70}
71
72impl Default for ModelConfig {
73 fn default() -> Self {
74 Self {
75 model: String::new(),
81 temperature: default_temperature(),
82 max_tokens: default_max_tokens(),
83 system_prompt: Some(prompts::get_system_prompt()),
84 dynamic_system_suffix: None,
85 reasoning: ReasoningLevel::default(),
86 hide_reasoning_trace: false,
87 is_subagent: false,
88 backend_options: HashMap::new(),
89 mcp_tools: Vec::new(),
90 }
91 }
92}
93
94impl ModelConfig {
95 pub fn get_backend_option(&self, backend: &str, key: &str) -> Option<&String> {
97 self.backend_options.get(backend)?.get(key)
98 }
99
100 pub fn get_backend_option_i32(&self, backend: &str, key: &str) -> Option<i32> {
102 self.get_backend_option(backend, key)?.parse::<i32>().ok()
103 }
104
105 pub fn get_backend_option_bool(&self, backend: &str, key: &str) -> Option<bool> {
107 self.get_backend_option(backend, key)?.parse::<bool>().ok()
108 }
109
110 pub fn set_backend_option(&mut self, backend: String, key: String, value: String) {
112 self.backend_options
113 .entry(backend)
114 .or_default()
115 .insert(key, value);
116 }
117
118 pub fn combined_system_prompt(&self) -> Option<String> {
126 match (
127 self.system_prompt.as_deref(),
128 self.dynamic_system_suffix.as_deref(),
129 ) {
130 (Some(s), Some(suffix)) if !s.is_empty() && !suffix.is_empty() => {
131 Some(format!("{}\n\n---\n\n{}", s, suffix))
132 },
133 (Some(s), _) if !s.is_empty() => Some(s.to_string()),
134 (_, Some(suffix)) if !suffix.is_empty() => Some(suffix.to_string()),
135 _ => None,
136 }
137 }
138
139 pub fn from_app_config(config: &crate::app::Config, model_id: &str) -> Self {
150 let reasoning = config
151 .reasoning_per_model
152 .get(model_id)
153 .copied()
154 .unwrap_or(config.default_model.reasoning);
155 let mut mc = Self {
156 model: model_id.to_string(),
157 temperature: config.default_model.temperature,
158 max_tokens: config.default_model.max_tokens,
159 reasoning,
160 ..Self::default()
161 };
162 if let Some(v) = config.ollama.num_gpu {
163 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
164 }
165 if let Some(v) = config.ollama.num_ctx {
166 mc.set_backend_option("ollama".into(), "num_ctx".into(), v.to_string());
167 }
168 if let Some(v) = config.ollama.num_thread {
169 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
170 }
171 if let Some(v) = config.ollama.numa {
172 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
173 }
174 mc
175 }
176
177 pub fn ollama_options(&self) -> OllamaOptions {
179 OllamaOptions {
180 num_gpu: self.get_backend_option_i32("ollama", "num_gpu"),
181 num_thread: self.get_backend_option_i32("ollama", "num_thread"),
182 num_ctx: self.get_backend_option_i32("ollama", "num_ctx"),
183 numa: self.get_backend_option_bool("ollama", "numa"),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Default)]
190pub struct OllamaOptions {
191 pub num_gpu: Option<i32>,
192 pub num_thread: Option<i32>,
193 pub num_ctx: Option<i32>,
194 pub numa: Option<bool>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct BackendConfig {
200 #[serde(default = "default_ollama_url")]
202 pub ollama_url: String,
203
204 #[serde(default = "default_timeout")]
206 pub timeout_secs: u64,
207
208 #[serde(default = "default_max_idle")]
210 pub max_idle_per_host: usize,
211}
212
213impl Default for BackendConfig {
214 fn default() -> Self {
215 Self {
216 ollama_url: default_ollama_url(),
217 timeout_secs: default_timeout(),
218 max_idle_per_host: default_max_idle(),
219 }
220 }
221}
222
223fn default_temperature() -> f32 {
225 DEFAULT_TEMPERATURE
226}
227
228fn default_max_tokens() -> usize {
229 DEFAULT_MAX_TOKENS
230}
231
232fn default_ollama_url() -> String {
233 "http://localhost:11434".to_string()
241}
242
243fn default_timeout() -> u64 {
244 10
245}
246
247fn default_max_idle() -> usize {
248 10
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
259 fn from_app_config_propagates_reasoning_from_settings() {
260 let mut cfg = crate::app::Config::default();
261 cfg.default_model.reasoning = ReasoningLevel::High;
262
263 let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
264 assert_eq!(mc.reasoning, ReasoningLevel::High);
265 assert_eq!(mc.model, "ollama/qwen3-coder:30b");
266 }
267
268 #[test]
269 fn from_app_config_uses_medium_default_when_unset() {
270 let cfg = crate::app::Config::default();
271 let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
272 assert_eq!(mc.reasoning, ReasoningLevel::Medium);
273 }
274
275 #[test]
279 fn from_app_config_uses_per_model_preference() {
280 let mut cfg = crate::app::Config::default();
281 cfg.default_model.reasoning = ReasoningLevel::Low;
282 cfg.reasoning_per_model.insert(
283 "anthropic/claude-sonnet-4-6".to_string(),
284 ReasoningLevel::High,
285 );
286
287 let mc_per_model = ModelConfig::from_app_config(&cfg, "anthropic/claude-sonnet-4-6");
288 assert_eq!(mc_per_model.reasoning, ReasoningLevel::High);
289
290 let mc_default = ModelConfig::from_app_config(&cfg, "ollama/foo");
292 assert_eq!(mc_default.reasoning, ReasoningLevel::Low);
293 }
294}