microclaw_engine/config/
llm_profiles.rs1use super::*;
2
3pub(crate) fn default_model() -> String {
4 String::new()
5}
6
7#[derive(Clone, Debug, Serialize, Deserialize)]
8pub struct ModelPrice {
9 pub model: String,
10 pub input_per_million_usd: f64,
11 pub output_per_million_usd: f64,
12}
13
14#[derive(Clone, Debug, Serialize, Deserialize, Default)]
15pub struct LlmProviderProfile {
16 #[serde(default)]
17 pub provider: Option<String>,
18 #[serde(default)]
19 pub api_key: Option<String>,
20 #[serde(default)]
22 pub api_keys: Vec<String>,
23 #[serde(default)]
24 pub llm_base_url: Option<String>,
25 #[serde(default)]
26 pub llm_user_agent: Option<String>,
27 #[serde(default)]
28 pub default_model: Option<String>,
29 #[serde(default)]
30 pub models: Vec<String>,
31 #[serde(default)]
32 pub show_thinking: Option<bool>,
33}
34
35#[derive(Clone, Debug)]
36pub struct ResolvedLlmProviderProfile {
37 pub alias: String,
38 pub provider: String,
39 pub api_key: String,
40 pub api_keys: Vec<String>,
42 pub llm_base_url: Option<String>,
43 pub llm_user_agent: String,
44 pub default_model: String,
45 pub models: Vec<String>,
46 pub show_thinking: bool,
47}
48
49#[derive(Clone, Debug, Default, Serialize, Deserialize)]
58pub struct AuxModels {
59 #[serde(default)]
62 pub compaction: Option<String>,
63 #[serde(default)]
67 pub reflector: Option<String>,
68 #[serde(default)]
72 pub title: Option<String>,
73 #[serde(default)]
76 pub vision: Option<String>,
77 #[serde(default)]
83 pub approval_reviewer: Option<String>,
84}
85
86impl AuxModels {
87 pub fn compaction_model<'a>(&'a self, main: &'a str) -> &'a str {
90 match self.compaction.as_deref() {
91 Some(m) if !m.trim().is_empty() => m,
92 _ => main,
93 }
94 }
95
96 pub fn reflector_model(&self) -> Option<&str> {
99 match self.reflector.as_deref() {
100 Some(m) if !m.trim().is_empty() => Some(m),
101 _ => None,
102 }
103 }
104
105 pub fn title_model(&self) -> Option<&str> {
108 match self.title.as_deref() {
109 Some(m) if !m.trim().is_empty() => Some(m),
110 _ => None,
111 }
112 }
113
114 pub fn vision_model(&self) -> Option<&str> {
117 match self.vision.as_deref() {
118 Some(m) if !m.trim().is_empty() => Some(m),
119 _ => None,
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 #[allow(unused_imports)]
128 use crate::config::test_prelude::*;
129
130 #[test]
131 fn aux_models_default_falls_back_to_main_model() {
132 let aux = AuxModels::default();
133 assert_eq!(aux.compaction_model("main-model"), "main-model");
134 }
135
136 #[test]
137 fn aux_models_compaction_override_is_used() {
138 let aux = AuxModels {
139 compaction: Some("cheap-model".to_string()),
140 ..Default::default()
141 };
142 assert_eq!(aux.compaction_model("main-model"), "cheap-model");
143 }
144
145 #[test]
146 fn aux_models_blank_override_falls_back() {
147 let aux = AuxModels {
148 compaction: Some(" ".to_string()),
149 ..Default::default()
150 };
151 assert_eq!(aux.compaction_model("main-model"), "main-model");
152 }
153
154 #[test]
155 fn aux_models_reflector_default_is_none() {
156 let aux = AuxModels::default();
157 assert_eq!(aux.reflector_model(), None);
158 }
159
160 #[test]
161 fn aux_models_reflector_blank_is_none() {
162 let aux = AuxModels {
163 reflector: Some(" ".to_string()),
164 ..Default::default()
165 };
166 assert_eq!(aux.reflector_model(), None);
167 }
168
169 #[test]
170 fn aux_models_title_and_vision_default_none() {
171 let aux = AuxModels::default();
172 assert_eq!(aux.title_model(), None);
173 assert_eq!(aux.vision_model(), None);
174 }
175
176 #[test]
177 fn aux_models_title_and_vision_override_used() {
178 let aux = AuxModels {
179 title: Some("title-model".to_string()),
180 vision: Some("vision-model".to_string()),
181 ..Default::default()
182 };
183 assert_eq!(aux.title_model(), Some("title-model"));
184 assert_eq!(aux.vision_model(), Some("vision-model"));
185 }
186
187 #[test]
188 fn aux_models_title_and_vision_blank_is_none() {
189 let aux = AuxModels {
190 title: Some(" ".to_string()),
191 vision: Some(String::new()),
192 ..Default::default()
193 };
194 assert_eq!(aux.title_model(), None);
195 assert_eq!(aux.vision_model(), None);
196 }
197
198 #[test]
199 fn test_post_deserialize_merges_provider_presets_with_legacy_profiles() {
200 let yaml = r#"
201telegram_bot_token: tok
202bot_username: bot
203api_key: key
204provider_presets:
205 lab:
206 provider: OPENAI
207 api_key: preset-key
208 llm_base_url: https://preset.example/v1
209 llm_user_agent: preset-agent
210 default_model: preset-model
211 models: [preset-model, preset-model]
212 show_thinking: true
213llm_providers:
214 lab:
215 api_key: legacy-key
216 models: [legacy-model]
217"#;
218 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
219 config.post_deserialize().unwrap();
220
221 let profile = config.resolve_llm_provider_profile("lab").unwrap();
222 assert_eq!(profile.provider, "openai");
223 assert_eq!(profile.api_key, "legacy-key");
224 assert_eq!(
225 profile.llm_base_url.as_deref(),
226 Some("https://preset.example/v1")
227 );
228 assert_eq!(profile.llm_user_agent, "preset-agent");
229 assert_eq!(profile.default_model, "preset-model");
230 assert_eq!(
231 profile.models,
232 vec!["legacy-model".to_string(), "preset-model".to_string()]
233 );
234 assert!(profile.show_thinking);
235 }
236
237 #[test]
238 fn test_resolve_llm_provider_profile_ignores_wildcard_profile_model() {
239 let yaml = r#"
240telegram_bot_token: tok
241bot_username: bot
242api_key: key
243llm_provider: openai
244model: gpt-5.2
245llm_providers:
246 openai:
247 provider: openai
248 default_model: "*"
249 models: ["*", "gpt-5-mini"]
250"#;
251 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
252 config.post_deserialize().unwrap();
253
254 let profile = config.resolve_llm_provider_profile("openai").unwrap();
255 assert_eq!(profile.default_model, "gpt-5.2");
256 assert_eq!(
257 profile.models,
258 vec!["gpt-5-mini".to_string(), "gpt-5.2".to_string()]
259 );
260 }
261
262 #[test]
263 fn test_post_deserialize_migrates_profile_aliases_out_of_channel_model_fields() {
264 let yaml = r#"
265telegram_bot_token: tok
266bot_username: bot
267api_key: key
268provider_presets:
269 googlegemini:
270 provider: google
271 default_model: gemini-2.5-pro
272channels:
273 telegram:
274 enabled: true
275 model: googlegemini
276 default_account: sales
277 accounts:
278 sales:
279 enabled: true
280 bot_token: tg_sales
281 model: googlegemini
282 discord:
283 enabled: true
284 model: googlegemini
285 accounts:
286 default:
287 enabled: true
288 bot_token: dc_tok
289"#;
290 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
291 config.post_deserialize().unwrap();
292
293 assert_eq!(
294 config.provider_override_for_channel("telegram").as_deref(),
295 Some("googlegemini")
296 );
297 assert_eq!(
298 config.provider_override_for_channel("discord").as_deref(),
299 Some("googlegemini")
300 );
301
302 let telegram = config.channels.get("telegram").unwrap();
303 assert_eq!(
304 telegram
305 .get("provider_preset")
306 .and_then(|v| v.as_str())
307 .map(str::trim),
308 Some("googlegemini")
309 );
310 assert!(telegram.get("model").is_none());
311
312 let sales = telegram
313 .get("accounts")
314 .and_then(|v| v.get("sales"))
315 .unwrap();
316 assert_eq!(
317 sales
318 .get("provider_preset")
319 .and_then(|v| v.as_str())
320 .map(str::trim),
321 Some("googlegemini")
322 );
323 assert!(sales.get("model").is_none());
324 }
325}