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(default)]
59 pub backend_options: HashMap<String, HashMap<String, String>>,
60
61 #[serde(skip)]
67 pub tools: Vec<serde_json::Value>,
68}
69
70impl Default for ModelConfig {
71 fn default() -> Self {
72 Self {
73 model: String::new(),
76 temperature: default_temperature(),
77 max_tokens: default_max_tokens(),
78 system_prompt: Some(prompts::get_system_prompt()),
79 dynamic_system_suffix: None,
80 reasoning: ReasoningLevel::default(),
81 hide_reasoning_trace: false,
82 backend_options: HashMap::new(),
83 tools: Vec::new(),
84 }
85 }
86}
87
88impl ModelConfig {
89 pub fn get_backend_option(&self, backend: &str, key: &str) -> Option<&String> {
91 self.backend_options.get(backend)?.get(key)
92 }
93
94 pub fn get_backend_option_i32(&self, backend: &str, key: &str) -> Option<i32> {
96 self.get_backend_option(backend, key)?.parse::<i32>().ok()
97 }
98
99 pub fn get_backend_option_bool(&self, backend: &str, key: &str) -> Option<bool> {
101 self.get_backend_option(backend, key)?.parse::<bool>().ok()
102 }
103
104 pub fn set_backend_option(&mut self, backend: String, key: String, value: String) {
106 self.backend_options
107 .entry(backend)
108 .or_default()
109 .insert(key, value);
110 }
111
112 pub fn combined_system_prompt(&self) -> Option<String> {
120 match (
121 self.system_prompt.as_deref(),
122 self.dynamic_system_suffix.as_deref(),
123 ) {
124 (Some(s), Some(suffix)) if !s.is_empty() && !suffix.is_empty() => {
125 Some(format!("{}\n\n---\n\n{}", s, suffix))
126 },
127 (Some(s), _) if !s.is_empty() => Some(s.to_string()),
128 (_, Some(suffix)) if !suffix.is_empty() => Some(suffix.to_string()),
129 _ => None,
130 }
131 }
132
133 pub fn from_app_config(config: &crate::app::Config, model_id: &str) -> Self {
144 let reasoning = config
145 .reasoning_per_model
146 .get(model_id)
147 .copied()
148 .unwrap_or(config.default_model.reasoning);
149 let mut mc = Self {
150 model: model_id.to_string(),
151 temperature: config.default_model.temperature,
152 max_tokens: config.default_model.max_tokens,
153 reasoning,
154 ..Self::default()
155 };
156 if let Some(v) = config.ollama.num_gpu {
157 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
158 }
159 if let Some(v) = config.ollama.num_ctx {
160 mc.set_backend_option("ollama".into(), "num_ctx".into(), v.to_string());
161 }
162 if let Some(v) = config.ollama.num_thread {
163 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
164 }
165 if let Some(v) = config.ollama.numa {
166 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
167 }
168 mc
169 }
170
171 pub fn ollama_options(&self) -> OllamaOptions {
173 OllamaOptions {
174 num_gpu: self.get_backend_option_i32("ollama", "num_gpu"),
175 num_thread: self.get_backend_option_i32("ollama", "num_thread"),
176 num_ctx: self.get_backend_option_i32("ollama", "num_ctx"),
177 numa: self.get_backend_option_bool("ollama", "numa"),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default)]
184pub struct OllamaOptions {
185 pub num_gpu: Option<i32>,
186 pub num_thread: Option<i32>,
187 pub num_ctx: Option<i32>,
188 pub numa: Option<bool>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct BackendConfig {
194 #[serde(default = "default_ollama_url")]
196 pub ollama_url: String,
197
198 #[serde(default = "default_timeout")]
200 pub timeout_secs: u64,
201
202 #[serde(default = "default_max_idle")]
204 pub max_idle_per_host: usize,
205}
206
207impl Default for BackendConfig {
208 fn default() -> Self {
209 Self {
210 ollama_url: default_ollama_url(),
211 timeout_secs: default_timeout(),
212 max_idle_per_host: default_max_idle(),
213 }
214 }
215}
216
217fn default_temperature() -> f32 {
219 DEFAULT_TEMPERATURE
220}
221
222fn default_max_tokens() -> usize {
223 DEFAULT_MAX_TOKENS
224}
225
226fn default_ollama_url() -> String {
227 "http://localhost:11434".to_string()
235}
236
237fn default_timeout() -> u64 {
238 10
239}
240
241fn default_max_idle() -> usize {
242 10
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
253 fn from_app_config_propagates_reasoning_from_settings() {
254 let mut cfg = crate::app::Config::default();
255 cfg.default_model.reasoning = ReasoningLevel::High;
256
257 let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
258 assert_eq!(mc.reasoning, ReasoningLevel::High);
259 assert_eq!(mc.model, "ollama/qwen3-coder:30b");
260 }
261
262 #[test]
263 fn from_app_config_uses_medium_default_when_unset() {
264 let cfg = crate::app::Config::default();
265 let mc = ModelConfig::from_app_config(&cfg, "ollama/qwen3-coder:30b");
266 assert_eq!(mc.reasoning, ReasoningLevel::Medium);
267 }
268
269 #[test]
273 fn from_app_config_uses_per_model_preference() {
274 let mut cfg = crate::app::Config::default();
275 cfg.default_model.reasoning = ReasoningLevel::Low;
276 cfg.reasoning_per_model.insert(
277 "anthropic/claude-sonnet-4-6".to_string(),
278 ReasoningLevel::High,
279 );
280
281 let mc_per_model = ModelConfig::from_app_config(&cfg, "anthropic/claude-sonnet-4-6");
282 assert_eq!(mc_per_model.reasoning, ReasoningLevel::High);
283
284 let mc_default = ModelConfig::from_app_config(&cfg, "ollama/foo");
286 assert_eq!(mc_default.reasoning, ReasoningLevel::Low);
287 }
288}