Skip to main content

zeroclaw_tools/
model_routing_config.rs

1use crate::util_helpers::MaybeSet;
2use async_trait::async_trait;
3use serde_json::{Value, json};
4use std::collections::BTreeMap;
5use std::fs;
6use std::sync::Arc;
7use zeroclaw_api::tool::{Tool, ToolResult};
8use zeroclaw_config::policy::SecurityPolicy;
9use zeroclaw_config::schema::{ClassificationRule, Config, ModelRouteConfig};
10
11const DEFAULT_AGENT_MAX_DEPTH: u32 = 3;
12const DEFAULT_AGENT_MAX_ITERATIONS: usize = 10;
13
14pub struct ModelRoutingConfigTool {
15    config: Arc<Config>,
16    security: Arc<SecurityPolicy>,
17}
18
19impl ModelRoutingConfigTool {
20    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
21        Self { config, security }
22    }
23
24    fn load_config_without_env(&self) -> anyhow::Result<Config> {
25        let contents = fs::read_to_string(&self.config.config_path).map_err(|error| {
26            ::zeroclaw_log::record!(
27                ERROR,
28                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
29                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
30                    .with_attrs(::serde_json::json!({
31                        "path": self.config.config_path.display().to_string(),
32                        "error": format!("{}", error),
33                    })),
34                "model_routing_config: failed to read config file"
35            );
36            anyhow::Error::msg(format!(
37                "Failed to read config file {}: {error}",
38                self.config.config_path.display()
39            ))
40        })?;
41
42        let mut parsed =
43            zeroclaw_config::migration::migrate_to_current(&contents).map_err(|error| {
44                ::zeroclaw_log::record!(
45                    ERROR,
46                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
47                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
48                        .with_attrs(::serde_json::json!({
49                            "path": self.config.config_path.display().to_string(),
50                            "error": format!("{}", error),
51                        })),
52                    "model_routing_config: failed to parse config file"
53                );
54                anyhow::Error::msg(format!(
55                    "Failed to parse config file {}: {error}",
56                    self.config.config_path.display()
57                ))
58            })?;
59        parsed.config_path = self.config.config_path.clone();
60        parsed.data_dir = self.config.data_dir.clone();
61        Ok(parsed)
62    }
63
64    fn require_write_access(&self) -> Option<ToolResult> {
65        if !self.security.can_act() {
66            return Some(ToolResult {
67                success: false,
68                output: String::new(),
69                error: Some("Action blocked: autonomy is read-only".into()),
70            });
71        }
72
73        if !self.security.record_action() {
74            return Some(ToolResult {
75                success: false,
76                output: String::new(),
77                error: Some("Action blocked: rate limit exceeded".into()),
78            });
79        }
80
81        None
82    }
83
84    fn parse_string_list(raw: &Value, field: &str) -> anyhow::Result<Vec<String>> {
85        if let Some(raw_string) = raw.as_str() {
86            return Ok(raw_string
87                .split(',')
88                .map(str::trim)
89                .filter(|entry| !entry.is_empty())
90                .map(ToOwned::to_owned)
91                .collect());
92        }
93
94        if let Some(array) = raw.as_array() {
95            let mut out = Vec::new();
96            for item in array {
97                let value = item.as_str().ok_or_else(|| {
98                    ::zeroclaw_log::record!(
99                        WARN,
100                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
101                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
102                            .with_attrs(::serde_json::json!({"field": field})),
103                        "model_routing_config: array element must be a string"
104                    );
105                    anyhow::Error::msg(format!("'{field}' array must only contain strings"))
106                })?;
107                let trimmed = value.trim();
108                if !trimmed.is_empty() {
109                    out.push(trimmed.to_string());
110                }
111            }
112            return Ok(out);
113        }
114
115        anyhow::bail!("'{field}' must be a string or string[]")
116    }
117
118    fn parse_non_empty_string(args: &Value, field: &str) -> anyhow::Result<String> {
119        let value = args
120            .get(field)
121            .and_then(Value::as_str)
122            .ok_or_else(|| {
123                ::zeroclaw_log::record!(
124                    WARN,
125                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
126                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
127                        .with_attrs(::serde_json::json!({"param": field})),
128                    "model_routing_config: missing required string param"
129                );
130                anyhow::Error::msg(format!("Missing '{field}'"))
131            })?
132            .trim();
133
134        if value.is_empty() {
135            anyhow::bail!("'{field}' must not be empty");
136        }
137
138        Ok(value.to_string())
139    }
140
141    fn parse_optional_string_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<String>> {
142        let Some(raw) = args.get(field) else {
143            return Ok(MaybeSet::Unset);
144        };
145
146        if raw.is_null() {
147            return Ok(MaybeSet::Null);
148        }
149
150        let value = raw
151            .as_str()
152            .ok_or_else(|| {
153                ::zeroclaw_log::record!(
154                    WARN,
155                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
156                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
157                        .with_attrs(::serde_json::json!({"field": field})),
158                    "model_routing_config: field must be string or null"
159                );
160                anyhow::Error::msg(format!("'{field}' must be a string or null"))
161            })?
162            .trim()
163            .to_string();
164
165        let output = if value.is_empty() {
166            MaybeSet::Null
167        } else {
168            MaybeSet::Set(value)
169        };
170        Ok(output)
171    }
172
173    fn parse_optional_f64_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<f64>> {
174        let Some(raw) = args.get(field) else {
175            return Ok(MaybeSet::Unset);
176        };
177
178        if raw.is_null() {
179            return Ok(MaybeSet::Null);
180        }
181
182        let value = raw.as_f64().ok_or_else(|| {
183            ::zeroclaw_log::record!(
184                WARN,
185                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
186                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
187                    .with_attrs(::serde_json::json!({"field": field})),
188                "model_routing_config: field must be number or null"
189            );
190            anyhow::Error::msg(format!("'{field}' must be a number or null"))
191        })?;
192        Ok(MaybeSet::Set(value))
193    }
194
195    fn parse_optional_usize_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<usize>> {
196        let Some(raw) = args.get(field) else {
197            return Ok(MaybeSet::Unset);
198        };
199
200        if raw.is_null() {
201            return Ok(MaybeSet::Null);
202        }
203
204        let raw_value = raw.as_u64().ok_or_else(|| {
205            ::zeroclaw_log::record!(
206                WARN,
207                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
208                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
209                    .with_attrs(::serde_json::json!({"field": field})),
210                "model_routing_config: usize field must be non-negative integer or null"
211            );
212            anyhow::Error::msg(format!("'{field}' must be a non-negative integer or null"))
213        })?;
214        let value = usize::try_from(raw_value).map_err(|_| {
215            ::zeroclaw_log::record!(
216                WARN,
217                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
218                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
219                    .with_attrs(::serde_json::json!({"field": field, "raw_value": raw_value})),
220                "model_routing_config: usize value too large"
221            );
222            anyhow::Error::msg(format!("'{field}' is too large for this platform"))
223        })?;
224        Ok(MaybeSet::Set(value))
225    }
226
227    fn parse_optional_u32_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<u32>> {
228        let Some(raw) = args.get(field) else {
229            return Ok(MaybeSet::Unset);
230        };
231
232        if raw.is_null() {
233            return Ok(MaybeSet::Null);
234        }
235
236        let raw_value = raw.as_u64().ok_or_else(|| {
237            ::zeroclaw_log::record!(
238                WARN,
239                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
240                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
241                    .with_attrs(::serde_json::json!({"field": field})),
242                "model_routing_config: u32 field must be non-negative integer or null"
243            );
244            anyhow::Error::msg(format!("'{field}' must be a non-negative integer or null"))
245        })?;
246        let value = u32::try_from(raw_value).map_err(|_| {
247            ::zeroclaw_log::record!(
248                WARN,
249                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
250                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
251                    .with_attrs(::serde_json::json!({"field": field, "raw_value": raw_value})),
252                "model_routing_config: u32 value too large"
253            );
254            anyhow::Error::msg(format!("'{field}' must fit in u32"))
255        })?;
256        Ok(MaybeSet::Set(value))
257    }
258
259    fn parse_optional_i32_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<i32>> {
260        let Some(raw) = args.get(field) else {
261            return Ok(MaybeSet::Unset);
262        };
263
264        if raw.is_null() {
265            return Ok(MaybeSet::Null);
266        }
267
268        let raw_value = raw.as_i64().ok_or_else(|| {
269            ::zeroclaw_log::record!(
270                WARN,
271                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
272                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
273                    .with_attrs(::serde_json::json!({"field": field})),
274                "model_routing_config: i32 field must be integer or null"
275            );
276            anyhow::Error::msg(format!("'{field}' must be an integer or null"))
277        })?;
278        let value = i32::try_from(raw_value).map_err(|_| {
279            ::zeroclaw_log::record!(
280                WARN,
281                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
282                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
283                    .with_attrs(::serde_json::json!({"field": field, "raw_value": raw_value})),
284                "model_routing_config: i32 value out of range"
285            );
286            anyhow::Error::msg(format!("'{field}' must fit in i32"))
287        })?;
288        Ok(MaybeSet::Set(value))
289    }
290
291    fn parse_optional_bool(args: &Value, field: &str) -> anyhow::Result<Option<bool>> {
292        let Some(raw) = args.get(field) else {
293            return Ok(None);
294        };
295
296        let value = raw.as_bool().ok_or_else(|| {
297            ::zeroclaw_log::record!(
298                WARN,
299                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
300                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
301                    .with_attrs(::serde_json::json!({"field": field})),
302                "model_routing_config: field must be boolean"
303            );
304            anyhow::Error::msg(format!("'{field}' must be a boolean"))
305        })?;
306        Ok(Some(value))
307    }
308
309    fn scenario_row(route: &ModelRouteConfig, rule: Option<&ClassificationRule>) -> Value {
310        let classification = rule.map(|r| {
311            json!({
312                "keywords": r.keywords,
313                "patterns": r.patterns,
314                "min_length": r.min_length,
315                "max_length": r.max_length,
316                "priority": r.priority,
317            })
318        });
319
320        json!({
321            "hint": route.hint,
322            "model_provider": route.model_provider,
323            "model": route.model,
324            "api_key_configured": route
325                .api_key
326                .as_ref()
327                .is_some_and(|value| !value.trim().is_empty()),
328            "classification": classification,
329        })
330    }
331
332    fn snapshot(cfg: &Config) -> Value {
333        let mut routes = cfg.model_routes.clone();
334        routes.sort_by(|a, b| a.hint.cmp(&b.hint));
335
336        let mut rules = cfg.query_classification.rules.clone();
337        rules.sort_by(|a, b| {
338            b.priority
339                .cmp(&a.priority)
340                .then_with(|| a.hint.cmp(&b.hint))
341        });
342
343        let mut scenarios = Vec::with_capacity(routes.len());
344        for route in &routes {
345            let rule = rules.iter().find(|r| r.hint == route.hint);
346            scenarios.push(Self::scenario_row(route, rule));
347        }
348
349        let classification_only_rules: Vec<Value> = rules
350            .iter()
351            .filter(|rule| !routes.iter().any(|route| route.hint == rule.hint))
352            .map(|rule| {
353                json!({
354                    "hint": rule.hint,
355                    "keywords": rule.keywords,
356                    "patterns": rule.patterns,
357                    "min_length": rule.min_length,
358                    "max_length": rule.max_length,
359                    "priority": rule.priority,
360                })
361            })
362            .collect();
363
364        let mut agents: BTreeMap<String, Value> = BTreeMap::new();
365        for (name, agent) in &cfg.agents {
366            let risk = cfg.risk_profiles.get(&agent.risk_profile);
367            let runtime = cfg.runtime_profiles.get(&agent.runtime_profile);
368            agents.insert(
369                name.clone(),
370                json!({
371                    "model_provider": agent.model_provider,
372                    "risk_profile": agent.risk_profile,
373                    "runtime_profile": agent.runtime_profile,
374                    "max_delegation_depth": runtime.map(|r| r.max_delegation_depth),
375                    "agentic": runtime.map(|r| r.agentic),
376                    "allowed_tools": risk.map(|r| &r.allowed_tools),
377                    "max_tool_iterations": runtime.map(|r| r.max_tool_iterations),
378                }),
379            );
380        }
381
382        json!({
383            "query_classification": {
384                "enabled": cfg.query_classification.enabled,
385                "rules_count": cfg.query_classification.rules.len(),
386            },
387            "scenarios": scenarios,
388            "classification_only_rules": classification_only_rules,
389            "agents": agents,
390        })
391    }
392
393    fn normalize_and_sort_routes(routes: &mut Vec<ModelRouteConfig>) {
394        routes.retain(|route| !route.hint.trim().is_empty());
395        routes.sort_by(|a, b| a.hint.cmp(&b.hint));
396    }
397
398    fn normalize_and_sort_rules(rules: &mut Vec<ClassificationRule>) {
399        rules.retain(|rule| !rule.hint.trim().is_empty());
400        rules.sort_by(|a, b| {
401            b.priority
402                .cmp(&a.priority)
403                .then_with(|| a.hint.cmp(&b.hint))
404        });
405    }
406
407    fn has_rule_matcher(rule: &ClassificationRule) -> bool {
408        !rule.keywords.is_empty()
409            || !rule.patterns.is_empty()
410            || rule.min_length.is_some()
411            || rule.max_length.is_some()
412    }
413
414    fn ensure_rule_defaults(rule: &mut ClassificationRule, hint: &str) {
415        if !Self::has_rule_matcher(rule) {
416            rule.keywords = vec![hint.to_string()];
417        }
418    }
419
420    fn handle_get(&self) -> anyhow::Result<ToolResult> {
421        let cfg = self.load_config_without_env()?;
422        Ok(ToolResult {
423            success: true,
424            output: serde_json::to_string_pretty(&Self::snapshot(&cfg))?,
425            error: None,
426        })
427    }
428
429    fn handle_list_hints(&self) -> anyhow::Result<ToolResult> {
430        let cfg = self.load_config_without_env()?;
431        let mut route_hints: Vec<String> =
432            cfg.model_routes.iter().map(|r| r.hint.clone()).collect();
433        route_hints.sort();
434        route_hints.dedup();
435
436        let mut classification_hints: Vec<String> = cfg
437            .query_classification
438            .rules
439            .iter()
440            .map(|r| r.hint.clone())
441            .collect();
442        classification_hints.sort();
443        classification_hints.dedup();
444
445        Ok(ToolResult {
446            success: true,
447            output: serde_json::to_string_pretty(&json!({
448                "model_route_hints": route_hints,
449                "classification_hints": classification_hints,
450                "example": {
451                    "conversation": {
452                        "action": "upsert_scenario",
453                        "hint": "conversation",
454                        "model_provider": "kimi",
455                        "model": "moonshot-v1-8k",
456                        "classification_enabled": false
457                    },
458                    "coding": {
459                        "action": "upsert_scenario",
460                        "hint": "coding",
461                        "model_provider": "openai",
462                        "model": "gpt-5.3-codex",
463                        "classification_enabled": true,
464                        "keywords": ["code", "bug", "refactor", "test"],
465                        "patterns": ["```"],
466                        "priority": 50
467                    }
468                }
469            }))?,
470            error: None,
471        })
472    }
473
474    async fn handle_set_default(&self, args: &Value) -> anyhow::Result<ToolResult> {
475        let provider_update = Self::parse_optional_string_update(args, "model_provider")?;
476        let model_update = Self::parse_optional_string_update(args, "model")?;
477        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
478
479        let any_update = !matches!(provider_update, MaybeSet::Unset)
480            || !matches!(model_update, MaybeSet::Unset)
481            || !matches!(temperature_update, MaybeSet::Unset);
482
483        if !any_update {
484            anyhow::bail!(
485                "set_default requires at least one of: model_provider, model, temperature"
486            );
487        }
488
489        let mut cfg = self.load_config_without_env()?;
490
491        // Determine which models entry to update.
492        let (type_k, alias_k) = match &provider_update {
493            MaybeSet::Set(model_provider) => model_provider
494                .split_once('.')
495                .map(|(t, a)| (t.to_string(), a.to_string()))
496                .unwrap_or_else(|| (model_provider.clone(), "default".to_string())),
497            MaybeSet::Null | MaybeSet::Unset => {
498                // Update whichever entry already exists, or create a placeholder.
499                cfg.providers
500                    .models
501                    .iter_entries()
502                    .next()
503                    .map(|(t, a, _)| (t.to_string(), a.to_string()))
504                    .unwrap_or_else(|| ("custom".to_string(), "default".to_string()))
505            }
506        };
507
508        // Capture previous provider entry for rollback on probe failure.
509        let previous_provider_entry = cfg.providers.models.find(&type_k, &alias_k).cloned();
510        let entry = cfg
511            .providers
512            .models
513            .ensure(&type_k, &alias_k)
514            .ok_or_else(|| {
515                ::zeroclaw_log::record!(
516                    ERROR,
517                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
518                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
519                        .with_attrs(::serde_json::json!({
520                            "model_provider_type": &type_k,
521                            "alias": &alias_k,
522                        })),
523                    "model_routing_config: unknown model_provider type"
524                );
525                anyhow::Error::msg(format!(
526                    "unknown model_provider type `{type_k}`. no typed slot in ModelProviders"
527                ))
528            })?;
529
530        match model_update {
531            MaybeSet::Set(model) => entry.model = Some(model),
532            MaybeSet::Null => entry.model = None,
533            MaybeSet::Unset => {}
534        }
535
536        match temperature_update {
537            MaybeSet::Set(temperature) => {
538                if !(0.0..=2.0).contains(&temperature) {
539                    anyhow::bail!("'temperature' must be between 0.0 and 2.0");
540                }
541                entry.temperature = Some(temperature);
542            }
543            MaybeSet::Null => {
544                entry.temperature = None;
545            }
546            MaybeSet::Unset => {}
547        }
548
549        cfg.save().await?;
550
551        // Probe the new model with a minimal API call to catch invalid model IDs
552        // before the channel hot-reload picks up the change.
553        let current_model = cfg
554            .providers
555            .models
556            .find(&type_k, &alias_k)
557            .and_then(|e| e.model.clone());
558        let provider_name = format!("{type_k}.{alias_k}");
559        if let Some(model_name) = current_model
560            && let Err(probe_err) = self.probe_model(&provider_name, &model_name).await
561        {
562            if zeroclaw_providers::reliable::is_non_retryable(&probe_err) {
563                let reverted_model = previous_provider_entry
564                    .as_ref()
565                    .and_then(|e| e.model.as_deref())
566                    .unwrap_or("(none)")
567                    .to_string();
568
569                // Rollback: restore the previous entry's baseline fields for
570                // this type.alias slot. Family-specific extras on the typed
571                // family config are NOT touched — they survive the modify+
572                // restore cycle because we only ever mutated baseline fields
573                // (model, temperature, api_key) above.
574                if let Some(prev_entry) = previous_provider_entry
575                    && let Some(slot) = cfg.providers.models.ensure(&type_k, &alias_k)
576                {
577                    *slot = prev_entry;
578                }
579                cfg.save().await?;
580
581                return Ok(ToolResult {
582                    success: false,
583                    output: format!(
584                        "Model '{model_name}' is not available: {probe_err}. Reverted to '{reverted_model}'.",
585                    ),
586                    error: None,
587                });
588            }
589            // Retryable errors (e.g. transient network issues) — keep the
590            // new config and let the resilient wrapper handle retries.
591            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model": model_name, "probe_err": probe_err.to_string()})), "Model probe returned retryable error (keeping new config)");
592        }
593
594        Ok(ToolResult {
595            success: true,
596            output: serde_json::to_string_pretty(&json!({
597                "message": "Default model_provider/model settings updated",
598                "config": Self::snapshot(&cfg),
599            }))?,
600            error: None,
601        })
602    }
603
604    /// Send a minimal 1-token chat request to verify the model is accessible.
605    /// Returns `Ok(())` if the probe succeeds **or** if no API key is available
606    /// (the probe would fail with an auth error unrelated to model validity).
607    /// ModelProvider construction failures are also treated as non-fatal.
608    async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
609        // Use the runtime config's API key (which includes env-sourced keys),
610        // not the on-disk config (which may have no key at all).
611        let (family, alias) = provider_name
612            .split_once('.')
613            .unwrap_or((provider_name, "default"));
614        let entry = self.config.providers.models.find(family, alias);
615        let api_key = entry.and_then(|e| e.api_key.as_deref());
616        if api_key.is_none_or(|k| k.trim().is_empty()) {
617            return Ok(());
618        }
619
620        let model_provider = match zeroclaw_providers::create_model_provider_with_url(
621            provider_name,
622            api_key,
623            entry.and_then(|e| e.uri.as_deref()),
624        ) {
625            Ok(p) => p,
626            Err(_) => return Ok(()),
627        };
628
629        // Greedy sampling: the ping is a liveness check, not a generation task.
630        const PING_TEMPERATURE: f64 = 0.0;
631        model_provider
632            .chat_with_system(
633                Some("Respond with OK."),
634                "ping",
635                model,
636                Some(PING_TEMPERATURE),
637            )
638            .await?;
639
640        Ok(())
641    }
642
643    async fn handle_upsert_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
644        let hint = Self::parse_non_empty_string(args, "hint")?;
645        let model_provider = Self::parse_non_empty_string(args, "model_provider")?;
646        let model = Self::parse_non_empty_string(args, "model")?;
647        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
648
649        let keywords_update = if let Some(raw) = args.get("keywords") {
650            Some(Self::parse_string_list(raw, "keywords")?)
651        } else {
652            None
653        };
654        let patterns_update = if let Some(raw) = args.get("patterns") {
655            Some(Self::parse_string_list(raw, "patterns")?)
656        } else {
657            None
658        };
659        let min_length_update = Self::parse_optional_usize_update(args, "min_length")?;
660        let max_length_update = Self::parse_optional_usize_update(args, "max_length")?;
661        let priority_update = Self::parse_optional_i32_update(args, "priority")?;
662        let classification_enabled = Self::parse_optional_bool(args, "classification_enabled")?;
663
664        let should_touch_rule = classification_enabled.is_some()
665            || keywords_update.is_some()
666            || patterns_update.is_some()
667            || !matches!(min_length_update, MaybeSet::Unset)
668            || !matches!(max_length_update, MaybeSet::Unset)
669            || !matches!(priority_update, MaybeSet::Unset);
670
671        let mut cfg = self.load_config_without_env()?;
672
673        let existing_route = cfg
674            .model_routes
675            .iter()
676            .find(|route| route.hint == hint)
677            .cloned();
678
679        let mut next_route = existing_route.unwrap_or(ModelRouteConfig {
680            hint: hint.clone(),
681            model_provider: model_provider.clone(),
682            model: model.clone(),
683            api_key: None,
684        });
685
686        next_route.hint = hint.clone();
687        next_route.model_provider = model_provider;
688        next_route.model = model;
689
690        match api_key_update {
691            MaybeSet::Set(api_key) => next_route.api_key = Some(api_key),
692            MaybeSet::Null => next_route.api_key = None,
693            MaybeSet::Unset => {}
694        }
695
696        cfg.model_routes.retain(|route| route.hint != hint);
697        cfg.model_routes.push(next_route);
698        Self::normalize_and_sort_routes(&mut cfg.model_routes);
699
700        if should_touch_rule {
701            if matches!(classification_enabled, Some(false)) {
702                cfg.query_classification
703                    .rules
704                    .retain(|rule| rule.hint != hint);
705            } else {
706                let existing_rule = cfg
707                    .query_classification
708                    .rules
709                    .iter()
710                    .find(|rule| rule.hint == hint)
711                    .cloned();
712
713                let mut next_rule = existing_rule.unwrap_or_else(|| ClassificationRule {
714                    hint: hint.clone(),
715                    ..ClassificationRule::default()
716                });
717
718                if let Some(keywords) = keywords_update {
719                    next_rule.keywords = keywords;
720                }
721                if let Some(patterns) = patterns_update {
722                    next_rule.patterns = patterns;
723                }
724
725                match min_length_update {
726                    MaybeSet::Set(value) => next_rule.min_length = Some(value),
727                    MaybeSet::Null => next_rule.min_length = None,
728                    MaybeSet::Unset => {}
729                }
730
731                match max_length_update {
732                    MaybeSet::Set(value) => next_rule.max_length = Some(value),
733                    MaybeSet::Null => next_rule.max_length = None,
734                    MaybeSet::Unset => {}
735                }
736
737                match priority_update {
738                    MaybeSet::Set(value) => next_rule.priority = value,
739                    MaybeSet::Null => next_rule.priority = 0,
740                    MaybeSet::Unset => {}
741                }
742
743                if matches!(classification_enabled, Some(true)) {
744                    Self::ensure_rule_defaults(&mut next_rule, &hint);
745                }
746
747                if !Self::has_rule_matcher(&next_rule) {
748                    anyhow::bail!(
749                        "Classification rule for hint '{hint}' has no matching criteria. Provide keywords/patterns or set min_length/max_length."
750                    );
751                }
752
753                cfg.query_classification
754                    .rules
755                    .retain(|rule| rule.hint != hint);
756                cfg.query_classification.rules.push(next_rule);
757            }
758        }
759
760        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
761        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
762
763        cfg.save().await?;
764
765        Ok(ToolResult {
766            success: true,
767            output: serde_json::to_string_pretty(&json!({
768                "message": "Scenario route upserted",
769                "hint": hint,
770                "config": Self::snapshot(&cfg),
771            }))?,
772            error: None,
773        })
774    }
775
776    async fn handle_remove_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
777        let hint = Self::parse_non_empty_string(args, "hint")?;
778        let remove_classification = args
779            .get("remove_classification")
780            .and_then(Value::as_bool)
781            .unwrap_or(true);
782
783        let mut cfg = self.load_config_without_env()?;
784
785        let before_routes = cfg.model_routes.len();
786        cfg.model_routes.retain(|route| route.hint != hint);
787        let routes_removed = before_routes.saturating_sub(cfg.model_routes.len());
788
789        let mut rules_removed = 0usize;
790        if remove_classification {
791            let before_rules = cfg.query_classification.rules.len();
792            cfg.query_classification
793                .rules
794                .retain(|rule| rule.hint != hint);
795            rules_removed = before_rules.saturating_sub(cfg.query_classification.rules.len());
796        }
797
798        if routes_removed == 0 && rules_removed == 0 {
799            anyhow::bail!("No scenario found for hint '{hint}'");
800        }
801
802        Self::normalize_and_sort_routes(&mut cfg.model_routes);
803        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
804        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
805
806        cfg.save().await?;
807
808        Ok(ToolResult {
809            success: true,
810            output: serde_json::to_string_pretty(&json!({
811                "message": "Scenario removed",
812                "hint": hint,
813                "routes_removed": routes_removed,
814                "classification_rules_removed": rules_removed,
815                "config": Self::snapshot(&cfg),
816            }))?,
817            error: None,
818        })
819    }
820
821    async fn handle_upsert_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
822        let name = Self::parse_non_empty_string(args, "name")?;
823        let model_provider = Self::parse_non_empty_string(args, "model_provider")?;
824        let model = Self::parse_non_empty_string(args, "model")?;
825
826        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
827        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
828        let max_depth_update = Self::parse_optional_u32_update(args, "max_depth")?;
829        let max_iterations_update = Self::parse_optional_usize_update(args, "max_iterations")?;
830        let agentic_update = Self::parse_optional_bool(args, "agentic")?;
831
832        let allowed_tools_update = if let Some(raw) = args.get("allowed_tools") {
833            Some(Self::parse_string_list(raw, "allowed_tools")?)
834        } else {
835            None
836        };
837
838        let mut cfg = self.load_config_without_env()?;
839
840        // synthesize providers.models[model_provider_family][name] from inline brain params.
841        // The arg is the family name (e.g. "openai"); the agent's `model_provider`
842        // reference becomes the dotted form (e.g. "openai.coder").
843        let model_provider_family = model_provider;
844        let agent_model_provider_ref = format!("{model_provider_family}.{name}");
845        {
846            let provider_entry =
847                cfg.providers.models
848                    .ensure(&model_provider_family, &name)
849                    .ok_or_else(|| {
850                        ::zeroclaw_log::record!(
851                            ERROR,
852                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
853                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
854                                .with_attrs(::serde_json::json!({
855                                    "model_provider_family": &model_provider_family,
856                                    "name": &name,
857                                })),
858                            "model_routing_config: unknown model_provider family"
859                        );
860                        anyhow::Error::msg(format!(
861                            "unknown model_provider type `{model_provider_family}`. no typed slot in ModelProviders"
862                        ))
863                    })?;
864            provider_entry.model = Some(model.clone());
865            match api_key_update {
866                MaybeSet::Set(ref v) => provider_entry.api_key = Some(v.clone()),
867                MaybeSet::Null => provider_entry.api_key = None,
868                MaybeSet::Unset => {}
869            }
870            match temperature_update {
871                MaybeSet::Set(value) => {
872                    if !(0.0..=2.0).contains(&value) {
873                        anyhow::bail!("'temperature' must be between 0.0 and 2.0");
874                    }
875                    provider_entry.temperature = Some(value);
876                }
877                MaybeSet::Null => provider_entry.temperature = None,
878                MaybeSet::Unset => {}
879            }
880        }
881
882        // synthesize risk_profiles[name] from allowed_tools (authorization).
883        {
884            let risk = cfg.risk_profiles.entry(name.clone()).or_default();
885            if let Some(tools) = allowed_tools_update {
886                risk.allowed_tools = tools;
887            }
888        }
889
890        // synthesize runtime_profiles[name] from agentic/max_iterations/max_depth.
891        {
892            let runtime = cfg.runtime_profiles.entry(name.clone()).or_default();
893            if let Some(agentic) = agentic_update {
894                runtime.agentic = agentic;
895            }
896            if let MaybeSet::Set(iters) = max_iterations_update {
897                if iters == 0 {
898                    anyhow::bail!("'max_iterations' must be greater than 0");
899                }
900                runtime.max_tool_iterations = iters;
901            } else if runtime.max_tool_iterations == 0 {
902                runtime.max_tool_iterations = DEFAULT_AGENT_MAX_ITERATIONS;
903            }
904            if let MaybeSet::Set(depth) = max_depth_update {
905                if depth == 0 {
906                    anyhow::bail!("'max_depth' must be greater than 0");
907                }
908                runtime.max_delegation_depth = depth;
909            } else if runtime.max_delegation_depth == 0 {
910                runtime.max_delegation_depth = DEFAULT_AGENT_MAX_DEPTH;
911            }
912            if runtime.agentic {
913                let allowed_tools_empty = cfg
914                    .risk_profiles
915                    .get(&name)
916                    .is_none_or(|r| r.allowed_tools.is_empty());
917                if allowed_tools_empty {
918                    anyhow::bail!("Agent '{name}' has agentic=true but allowed_tools is empty.");
919                }
920            }
921        }
922
923        // Get or create the agent and wire up alias references.
924        let next_agent = cfg.agents.entry(name.clone()).or_default();
925        next_agent.model_provider = agent_model_provider_ref.into();
926        next_agent.risk_profile = name.clone();
927        next_agent.runtime_profile = name.clone();
928
929        cfg.save().await?;
930
931        Ok(ToolResult {
932            success: true,
933            output: serde_json::to_string_pretty(&json!({
934                "message": "Delegate agent upserted",
935                "name": name,
936                "config": Self::snapshot(&cfg),
937            }))?,
938            error: None,
939        })
940    }
941
942    async fn handle_remove_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
943        let name = Self::parse_non_empty_string(args, "name")?;
944
945        let mut cfg = self.load_config_without_env()?;
946        if cfg.agents.remove(&name).is_none() {
947            anyhow::bail!("No aliased agent found with name '{name}'");
948        }
949
950        cfg.save().await?;
951
952        Ok(ToolResult {
953            success: true,
954            output: serde_json::to_string_pretty(&json!({
955                "message": "Aliased agent removed",
956                "name": name,
957                "config": Self::snapshot(&cfg),
958            }))?,
959            error: None,
960        })
961    }
962}
963
964#[async_trait]
965impl Tool for ModelRoutingConfigTool {
966    fn name(&self) -> &str {
967        "model_routing_config"
968    }
969
970    fn description(&self) -> &str {
971        "Manage default model settings, scenario-based model_provider/model routes, classification rules, and aliased agent profiles"
972    }
973
974    fn parameters_schema(&self) -> Value {
975        json!({
976            "type": "object",
977            "properties": {
978                "action": {
979                    "type": "string",
980                    "enum": [
981                        "get",
982                        "list_hints",
983                        "set_default",
984                        "upsert_scenario",
985                        "remove_scenario",
986                        "upsert_agent",
987                        "remove_agent"
988                    ],
989                    "default": "get"
990                },
991                "hint": {
992                    "type": "string",
993                    "description": "Scenario hint name (for example: conversation, coding, reasoning)"
994                },
995                "model_provider": {
996                    "type": "string",
997                    "description": "ModelProvider for set_default/upsert_scenario/upsert_agent"
998                },
999                "model": {
1000                    "type": "string",
1001                    "description": "Model for set_default/upsert_scenario/upsert_agent"
1002                },
1003                "temperature": {
1004                    "type": ["number", "null"],
1005                    "description": "Optional temperature override (0.0-2.0)"
1006                },
1007                "api_key": {
1008                    "type": ["string", "null"],
1009                    "description": "Optional API key override for scenario route or aliased agent"
1010                },
1011                "keywords": {
1012                    "description": "Classification keywords for upsert_scenario (string or string array)",
1013                    "oneOf": [
1014                        {"type": "string"},
1015                        {"type": "array", "items": {"type": "string"}}
1016                    ]
1017                },
1018                "patterns": {
1019                    "description": "Classification literal patterns for upsert_scenario (string or string array)",
1020                    "oneOf": [
1021                        {"type": "string"},
1022                        {"type": "array", "items": {"type": "string"}}
1023                    ]
1024                },
1025                "min_length": {
1026                    "type": ["integer", "null"],
1027                    "minimum": 0,
1028                    "description": "Optional minimum message length matcher"
1029                },
1030                "max_length": {
1031                    "type": ["integer", "null"],
1032                    "minimum": 0,
1033                    "description": "Optional maximum message length matcher"
1034                },
1035                "priority": {
1036                    "type": ["integer", "null"],
1037                    "description": "Classification priority (higher runs first)"
1038                },
1039                "classification_enabled": {
1040                    "type": "boolean",
1041                    "description": "When true, upsert classification rule for this hint; false removes it"
1042                },
1043                "remove_classification": {
1044                    "type": "boolean",
1045                    "description": "When remove_scenario, whether to remove matching classification rule (default true)"
1046                },
1047                "name": {
1048                    "type": "string",
1049                    "description": "Aliased agent name for upsert_agent/remove_agent"
1050                },
1051                "max_depth": {
1052                    "type": ["integer", "null"],
1053                    "minimum": 1,
1054                    "description": "Delegate max recursion depth"
1055                },
1056                "agentic": {
1057                    "type": "boolean",
1058                    "description": "Enable tool-call loop mode for aliased agent"
1059                },
1060                "allowed_tools": {
1061                    "description": "Allowed tools for agentic delegate mode (string or string array)",
1062                    "oneOf": [
1063                        {"type": "string"},
1064                        {"type": "array", "items": {"type": "string"}}
1065                    ]
1066                },
1067                "max_iterations": {
1068                    "type": ["integer", "null"],
1069                    "minimum": 1,
1070                    "description": "Maximum tool-call iterations for agentic delegate mode"
1071                }
1072            },
1073            "additionalProperties": false
1074        })
1075    }
1076
1077    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1078        let action = args
1079            .get("action")
1080            .and_then(Value::as_str)
1081            .unwrap_or("get")
1082            .to_ascii_lowercase();
1083
1084        let result = match action.as_str() {
1085            "get" => self.handle_get(),
1086            "list_hints" => self.handle_list_hints(),
1087            "set_default" | "upsert_scenario" | "remove_scenario" | "upsert_agent"
1088            | "remove_agent" => {
1089                if let Some(blocked) = self.require_write_access() {
1090                    return Ok(blocked);
1091                }
1092
1093                match action.as_str() {
1094                    "set_default" => Box::pin(self.handle_set_default(&args)).await,
1095                    "upsert_scenario" => Box::pin(self.handle_upsert_scenario(&args)).await,
1096                    "remove_scenario" => Box::pin(self.handle_remove_scenario(&args)).await,
1097                    "upsert_agent" => Box::pin(self.handle_upsert_agent(&args)).await,
1098                    "remove_agent" => Box::pin(self.handle_remove_agent(&args)).await,
1099                    _ => unreachable!("validated above"),
1100                }
1101            }
1102            _ => anyhow::bail!(
1103                "Unknown action '{action}'. Valid: get, list_hints, set_default, upsert_scenario, remove_scenario, upsert_agent, remove_agent"
1104            ),
1105        };
1106
1107        match result {
1108            Ok(outcome) => Ok(outcome),
1109            Err(error) => Ok(ToolResult {
1110                success: false,
1111                output: String::new(),
1112                error: Some(error.to_string()),
1113            }),
1114        }
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use tempfile::TempDir;
1122    use zeroclaw_config::autonomy::AutonomyLevel;
1123    use zeroclaw_config::policy::SecurityPolicy;
1124
1125    fn test_security() -> Arc<SecurityPolicy> {
1126        Arc::new(SecurityPolicy {
1127            autonomy: AutonomyLevel::Supervised,
1128            workspace_dir: std::env::temp_dir(),
1129            ..SecurityPolicy::default()
1130        })
1131    }
1132
1133    fn readonly_security() -> Arc<SecurityPolicy> {
1134        Arc::new(SecurityPolicy {
1135            autonomy: AutonomyLevel::ReadOnly,
1136            workspace_dir: std::env::temp_dir(),
1137            ..SecurityPolicy::default()
1138        })
1139    }
1140
1141    async fn test_config(tmp: &TempDir) -> Arc<Config> {
1142        let config = Config {
1143            data_dir: tmp.path().join("data"),
1144            config_path: tmp.path().join("config.toml"),
1145            ..Config::default()
1146        };
1147        config.save().await.unwrap();
1148        Arc::new(config)
1149    }
1150
1151    fn read_saved_provider_entry(
1152        cfg_path: &std::path::Path,
1153        family: &str,
1154        alias: &str,
1155    ) -> Option<zeroclaw_config::schema::ModelProviderConfig> {
1156        let contents = std::fs::read_to_string(cfg_path).ok()?;
1157        let cfg = zeroclaw_config::migration::migrate_to_current(&contents).ok()?;
1158        cfg.providers.models.find(family, alias).cloned()
1159    }
1160
1161    #[tokio::test]
1162    async fn set_default_updates_provider_model_and_temperature() {
1163        let tmp = TempDir::new().unwrap();
1164        let cfg_path = tmp.path().join("config.toml");
1165        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1166
1167        let result = tool
1168            .execute(json!({
1169                "action": "set_default",
1170                "model_provider": "moonshot",
1171                "model": "moonshot-v1-8k",
1172                "temperature": 0.2
1173            }))
1174            .await
1175            .unwrap();
1176
1177        assert!(result.success, "{:?}", result.error);
1178        let entry = read_saved_provider_entry(&cfg_path, "moonshot", "default")
1179            .expect("set_default must materialize the moonshot.default slot");
1180        assert_eq!(entry.model.as_deref(), Some("moonshot-v1-8k"));
1181        assert_eq!(entry.temperature, Some(0.2));
1182    }
1183
1184    #[tokio::test]
1185    async fn upsert_scenario_creates_route_and_rule() {
1186        let tmp = TempDir::new().unwrap();
1187        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1188
1189        let result = tool
1190            .execute(json!({
1191                "action": "upsert_scenario",
1192                "hint": "coding",
1193                "model_provider": "openai",
1194                "model": "gpt-5.3-codex",
1195                "classification_enabled": true,
1196                "keywords": ["code", "bug", "refactor"],
1197                "patterns": ["```"],
1198                "priority": 50
1199            }))
1200            .await
1201            .unwrap();
1202
1203        assert!(result.success, "{:?}", result.error);
1204
1205        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1206        assert!(get_result.success);
1207        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1208
1209        assert_eq!(output["query_classification"]["enabled"], json!(true));
1210
1211        let scenarios = output["scenarios"].as_array().unwrap();
1212        assert!(scenarios.iter().any(|item| {
1213            item["hint"] == json!("coding")
1214                && item["model_provider"] == json!("openai")
1215                && item["model"] == json!("gpt-5.3-codex")
1216        }));
1217    }
1218
1219    #[tokio::test]
1220    async fn remove_scenario_also_removes_rule() {
1221        let tmp = TempDir::new().unwrap();
1222        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1223
1224        let _ = tool
1225            .execute(json!({
1226                "action": "upsert_scenario",
1227                "hint": "coding",
1228                "model_provider": "openai",
1229                "model": "gpt-5.3-codex",
1230                "classification_enabled": true,
1231                "keywords": ["code"]
1232            }))
1233            .await
1234            .unwrap();
1235
1236        let removed = tool
1237            .execute(json!({
1238                "action": "remove_scenario",
1239                "hint": "coding"
1240            }))
1241            .await
1242            .unwrap();
1243        assert!(removed.success, "{:?}", removed.error);
1244
1245        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1246        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1247        assert_eq!(output["query_classification"]["enabled"], json!(false));
1248        assert!(output["scenarios"].as_array().unwrap().is_empty());
1249    }
1250
1251    #[tokio::test]
1252    async fn upsert_and_remove_delegate_agent() {
1253        let tmp = TempDir::new().unwrap();
1254        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1255
1256        let upsert = tool
1257            .execute(json!({
1258                "action": "upsert_agent",
1259                "name": "coder",
1260                "model_provider": "openai",
1261                "model": "gpt-5.3-codex",
1262                "agentic": true,
1263                "allowed_tools": ["file_read", "file_write", "shell"],
1264                "max_iterations": 6
1265            }))
1266            .await
1267            .unwrap();
1268        assert!(upsert.success, "{:?}", upsert.error);
1269
1270        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1271        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1272        // V3 surfaces the dotted alias ref on the agent. The actual model
1273        // string lives under model_providers.openai.coder (synthesized
1274        // from the `model` upsert arg).
1275        assert_eq!(
1276            output["agents"]["coder"]["model_provider"],
1277            json!("openai.coder")
1278        );
1279        assert_eq!(output["agents"]["coder"]["agentic"], json!(true));
1280
1281        let remove = tool
1282            .execute(json!({
1283                "action": "remove_agent",
1284                "name": "coder"
1285            }))
1286            .await
1287            .unwrap();
1288        assert!(remove.success, "{:?}", remove.error);
1289
1290        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
1291        let output: Value = serde_json::from_str(&get_result.output).unwrap();
1292        assert!(output["agents"]["coder"].is_null());
1293    }
1294
1295    #[tokio::test]
1296    async fn read_only_mode_blocks_mutating_actions() {
1297        let tmp = TempDir::new().unwrap();
1298        let tool =
1299            ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, readonly_security());
1300
1301        let result = tool
1302            .execute(json!({
1303                "action": "set_default",
1304                "model_provider": "openai"
1305            }))
1306            .await
1307            .unwrap();
1308
1309        assert!(!result.success);
1310        assert!(result.error.unwrap_or_default().contains("read-only"));
1311    }
1312
1313    #[tokio::test]
1314    async fn set_default_skips_probe_without_api_key() {
1315        let tmp = TempDir::new().unwrap();
1316        let cfg_path = tmp.path().join("config.toml");
1317        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1318
1319        let result = tool
1320            .execute(json!({
1321                "action": "set_default",
1322                "model_provider": "anthropic",
1323                "model": "totally-fake-model-12345"
1324            }))
1325            .await
1326            .unwrap();
1327
1328        assert!(result.success, "{:?}", result.error);
1329        let entry = read_saved_provider_entry(&cfg_path, "anthropic", "default")
1330            .expect("set_default must materialize the anthropic.default slot");
1331        assert_eq!(entry.model.as_deref(), Some("totally-fake-model-12345"));
1332    }
1333
1334    #[tokio::test]
1335    async fn set_default_temperature_only_skips_probe() {
1336        let tmp = TempDir::new().unwrap();
1337        let cfg_path = tmp.path().join("config.toml");
1338        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1339
1340        let result = tool
1341            .execute(json!({
1342                "action": "set_default",
1343                "temperature": 1.5
1344            }))
1345            .await
1346            .unwrap();
1347
1348        assert!(result.success, "{:?}", result.error);
1349        let entry = read_saved_provider_entry(&cfg_path, "custom", "default")
1350            .expect("temperature-only set_default must create the custom.default placeholder slot");
1351        assert_eq!(entry.temperature, Some(1.5));
1352    }
1353}