Skip to main content

microclaw_engine/config/
llm_profiles.rs

1use 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    /// Additional API keys rotated on auth/rate-limit errors.
21    #[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    /// Additional API keys rotated on auth/rate-limit errors.
41    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/// Per-task auxiliary model overrides.
50///
51/// Each slot names a (typically cheaper) model used for a specific ancillary task
52/// instead of the main conversation model. Auxiliary models run on the *same*
53/// provider profile and credentials as the main model — only the model name is
54/// swapped — so the common case (e.g. a small/fast model for summarization) needs
55/// no extra provider configuration. An empty or unset slot falls back to the main
56/// model, so the default behavior is unchanged.
57#[derive(Clone, Debug, Default, Serialize, Deserialize)]
58pub struct AuxModels {
59    /// Model used for context compaction / history summarization. Falls back to the
60    /// main model when unset or empty.
61    #[serde(default)]
62    pub compaction: Option<String>,
63    /// Model used by the background memory reflector (fact/triple extraction). This
64    /// runs periodically per active chat, so a cheaper model here is pure savings.
65    /// Falls back to the main model when unset or empty.
66    #[serde(default)]
67    pub reflector: Option<String>,
68    /// Model used to generate short session titles. This is a one-shot
69    /// summarization, so a cheaper model is pure savings. Falls back to the
70    /// provider's default model when unset or empty.
71    #[serde(default)]
72    pub title: Option<String>,
73    /// Model used for image description (the `describe_image` tool). Overrides
74    /// `media.vision.model` when set; falls back to it when unset or empty.
75    #[serde(default)]
76    pub vision: Option<String>,
77    /// Model that writes a short advisory verdict on high-risk tool
78    /// approval prompts. Unlike the other slots this does NOT fall back to
79    /// the main model: unset means the reviewer is off (the default), so
80    /// enabling it is an explicit operator choice. Advisory only — it never
81    /// approves or denies anything itself.
82    #[serde(default)]
83    pub approval_reviewer: Option<String>,
84}
85
86impl AuxModels {
87    /// Resolve the model to use for context compaction, falling back to `main` when
88    /// no auxiliary model is configured.
89    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    /// Optional model override for the memory reflector. Returns `None` (use the
97    /// provider's default model) when unset or empty, preserving prior behavior.
98    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    /// Optional model override for session-title generation. Returns `None` (use
106    /// the provider's default model) when unset or empty, preserving prior behavior.
107    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    /// Optional model override for image description. Returns `None` (use
115    /// `media.vision.model`) when unset or empty, preserving prior behavior.
116    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}