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            "default": {
384                "model_provider": cfg.first_model_provider_type(),
385                "model": cfg.first_model_provider().and_then(|e| e.model.as_deref()),
386                "temperature": cfg.first_model_provider().and_then(|e| e.temperature).unwrap_or(0.7),
387            },
388            "query_classification": {
389                "enabled": cfg.query_classification.enabled,
390                "rules_count": cfg.query_classification.rules.len(),
391            },
392            "scenarios": scenarios,
393            "classification_only_rules": classification_only_rules,
394            "agents": agents,
395        })
396    }
397
398    fn normalize_and_sort_routes(routes: &mut Vec<ModelRouteConfig>) {
399        routes.retain(|route| !route.hint.trim().is_empty());
400        routes.sort_by(|a, b| a.hint.cmp(&b.hint));
401    }
402
403    fn normalize_and_sort_rules(rules: &mut Vec<ClassificationRule>) {
404        rules.retain(|rule| !rule.hint.trim().is_empty());
405        rules.sort_by(|a, b| {
406            b.priority
407                .cmp(&a.priority)
408                .then_with(|| a.hint.cmp(&b.hint))
409        });
410    }
411
412    fn has_rule_matcher(rule: &ClassificationRule) -> bool {
413        !rule.keywords.is_empty()
414            || !rule.patterns.is_empty()
415            || rule.min_length.is_some()
416            || rule.max_length.is_some()
417    }
418
419    fn ensure_rule_defaults(rule: &mut ClassificationRule, hint: &str) {
420        if !Self::has_rule_matcher(rule) {
421            rule.keywords = vec![hint.to_string()];
422        }
423    }
424
425    fn handle_get(&self) -> anyhow::Result<ToolResult> {
426        let cfg = self.load_config_without_env()?;
427        Ok(ToolResult {
428            success: true,
429            output: serde_json::to_string_pretty(&Self::snapshot(&cfg))?,
430            error: None,
431        })
432    }
433
434    fn handle_list_hints(&self) -> anyhow::Result<ToolResult> {
435        let cfg = self.load_config_without_env()?;
436        let mut route_hints: Vec<String> =
437            cfg.model_routes.iter().map(|r| r.hint.clone()).collect();
438        route_hints.sort();
439        route_hints.dedup();
440
441        let mut classification_hints: Vec<String> = cfg
442            .query_classification
443            .rules
444            .iter()
445            .map(|r| r.hint.clone())
446            .collect();
447        classification_hints.sort();
448        classification_hints.dedup();
449
450        Ok(ToolResult {
451            success: true,
452            output: serde_json::to_string_pretty(&json!({
453                "model_route_hints": route_hints,
454                "classification_hints": classification_hints,
455                "example": {
456                    "conversation": {
457                        "action": "upsert_scenario",
458                        "hint": "conversation",
459                        "model_provider": "kimi",
460                        "model": "moonshot-v1-8k",
461                        "classification_enabled": false
462                    },
463                    "coding": {
464                        "action": "upsert_scenario",
465                        "hint": "coding",
466                        "model_provider": "openai",
467                        "model": "gpt-5.3-codex",
468                        "classification_enabled": true,
469                        "keywords": ["code", "bug", "refactor", "test"],
470                        "patterns": ["```"],
471                        "priority": 50
472                    }
473                }
474            }))?,
475            error: None,
476        })
477    }
478
479    async fn handle_set_default(&self, args: &Value) -> anyhow::Result<ToolResult> {
480        let provider_update = Self::parse_optional_string_update(args, "model_provider")?;
481        let model_update = Self::parse_optional_string_update(args, "model")?;
482        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
483
484        let any_update = !matches!(provider_update, MaybeSet::Unset)
485            || !matches!(model_update, MaybeSet::Unset)
486            || !matches!(temperature_update, MaybeSet::Unset);
487
488        if !any_update {
489            anyhow::bail!(
490                "set_default requires at least one of: model_provider, model, temperature"
491            );
492        }
493
494        let mut cfg = self.load_config_without_env()?;
495
496        // Capture previous first-provider entry for rollback on probe failure.
497        let previous_first_model_provider = cfg.first_model_provider().cloned();
498
499        // Determine which models entry to update.
500        let (type_k, alias_k) = match &provider_update {
501            MaybeSet::Set(model_provider) => model_provider
502                .split_once('.')
503                .map(|(t, a)| (t.to_string(), a.to_string()))
504                .unwrap_or_else(|| (model_provider.clone(), "default".to_string())),
505            MaybeSet::Null | MaybeSet::Unset => {
506                // Update whichever entry is already first, or create a placeholder.
507                cfg.providers
508                    .models
509                    .iter_entries()
510                    .next()
511                    .map(|(t, a, _)| (t.to_string(), a.to_string()))
512                    .unwrap_or_else(|| ("custom".to_string(), "default".to_string()))
513            }
514        };
515        let entry = cfg
516            .providers
517            .models
518            .ensure(&type_k, &alias_k)
519            .ok_or_else(|| {
520                ::zeroclaw_log::record!(
521                    ERROR,
522                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
523                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
524                        .with_attrs(::serde_json::json!({
525                            "model_provider_type": &type_k,
526                            "alias": &alias_k,
527                        })),
528                    "model_routing_config: unknown model_provider type"
529                );
530                anyhow::Error::msg(format!(
531                    "unknown model_provider type `{type_k}`. no typed slot in ModelProviders"
532                ))
533            })?;
534
535        match model_update {
536            MaybeSet::Set(model) => entry.model = Some(model),
537            MaybeSet::Null => entry.model = None,
538            MaybeSet::Unset => {}
539        }
540
541        match temperature_update {
542            MaybeSet::Set(temperature) => {
543                if !(0.0..=2.0).contains(&temperature) {
544                    anyhow::bail!("'temperature' must be between 0.0 and 2.0");
545                }
546                entry.temperature = Some(temperature);
547            }
548            MaybeSet::Null => {
549                entry.temperature = None;
550            }
551            MaybeSet::Unset => {}
552        }
553
554        cfg.save().await?;
555
556        // Probe the new model with a minimal API call to catch invalid model IDs
557        // before the channel hot-reload picks up the change.
558        let current_model = cfg.first_model_provider().and_then(|e| e.model.clone());
559        let provider_name = format!("{type_k}.{alias_k}");
560        if let Some(model_name) = current_model
561            && let Err(probe_err) = self.probe_model(&provider_name, &model_name).await
562        {
563            if zeroclaw_providers::reliable::is_non_retryable(&probe_err) {
564                let reverted_model = previous_first_model_provider
565                    .as_ref()
566                    .and_then(|e| e.model.as_deref())
567                    .unwrap_or("(none)")
568                    .to_string();
569
570                // Rollback: restore the previous entry's baseline fields for
571                // this type.alias slot. Family-specific extras on the typed
572                // family config are NOT touched — they survive the modify+
573                // restore cycle because we only ever mutated baseline fields
574                // (model, temperature, api_key) above.
575                if let Some(prev_entry) = previous_first_model_provider
576                    && let Some(slot) = cfg.providers.models.ensure(&type_k, &alias_k)
577                {
578                    *slot = prev_entry;
579                }
580                cfg.save().await?;
581
582                return Ok(ToolResult {
583                    success: false,
584                    output: format!(
585                        "Model '{model_name}' is not available: {probe_err}. Reverted to '{reverted_model}'.",
586                    ),
587                    error: None,
588                });
589            }
590            // Retryable errors (e.g. transient network issues) — keep the
591            // new config and let the resilient wrapper handle retries.
592            ::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)");
593        }
594
595        Ok(ToolResult {
596            success: true,
597            output: serde_json::to_string_pretty(&json!({
598                "message": "Default model_provider/model settings updated",
599                "config": Self::snapshot(&cfg),
600            }))?,
601            error: None,
602        })
603    }
604
605    /// Send a minimal 1-token chat request to verify the model is accessible.
606    /// Returns `Ok(())` if the probe succeeds **or** if no API key is available
607    /// (the probe would fail with an auth error unrelated to model validity).
608    /// ModelProvider construction failures are also treated as non-fatal.
609    async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
610        // Use the runtime config's API key (which includes env-sourced keys),
611        // not the on-disk config (which may have no key at all).
612        let api_key = self
613            .config
614            .first_model_provider()
615            .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            self.config
624                .first_model_provider()
625                .and_then(|e| e.uri.as_deref()),
626        ) {
627            Ok(p) => p,
628            Err(_) => return Ok(()),
629        };
630
631        // Greedy sampling: the ping is a liveness check, not a generation task.
632        const PING_TEMPERATURE: f64 = 0.0;
633        model_provider
634            .chat_with_system(
635                Some("Respond with OK."),
636                "ping",
637                model,
638                Some(PING_TEMPERATURE),
639            )
640            .await?;
641
642        Ok(())
643    }
644
645    async fn handle_upsert_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
646        let hint = Self::parse_non_empty_string(args, "hint")?;
647        let model_provider = Self::parse_non_empty_string(args, "model_provider")?;
648        let model = Self::parse_non_empty_string(args, "model")?;
649        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
650
651        let keywords_update = if let Some(raw) = args.get("keywords") {
652            Some(Self::parse_string_list(raw, "keywords")?)
653        } else {
654            None
655        };
656        let patterns_update = if let Some(raw) = args.get("patterns") {
657            Some(Self::parse_string_list(raw, "patterns")?)
658        } else {
659            None
660        };
661        let min_length_update = Self::parse_optional_usize_update(args, "min_length")?;
662        let max_length_update = Self::parse_optional_usize_update(args, "max_length")?;
663        let priority_update = Self::parse_optional_i32_update(args, "priority")?;
664        let classification_enabled = Self::parse_optional_bool(args, "classification_enabled")?;
665
666        let should_touch_rule = classification_enabled.is_some()
667            || keywords_update.is_some()
668            || patterns_update.is_some()
669            || !matches!(min_length_update, MaybeSet::Unset)
670            || !matches!(max_length_update, MaybeSet::Unset)
671            || !matches!(priority_update, MaybeSet::Unset);
672
673        let mut cfg = self.load_config_without_env()?;
674
675        let existing_route = cfg
676            .model_routes
677            .iter()
678            .find(|route| route.hint == hint)
679            .cloned();
680
681        let mut next_route = existing_route.unwrap_or(ModelRouteConfig {
682            hint: hint.clone(),
683            model_provider: model_provider.clone(),
684            model: model.clone(),
685            api_key: None,
686        });
687
688        next_route.hint = hint.clone();
689        next_route.model_provider = model_provider;
690        next_route.model = model;
691
692        match api_key_update {
693            MaybeSet::Set(api_key) => next_route.api_key = Some(api_key),
694            MaybeSet::Null => next_route.api_key = None,
695            MaybeSet::Unset => {}
696        }
697
698        cfg.model_routes.retain(|route| route.hint != hint);
699        cfg.model_routes.push(next_route);
700        Self::normalize_and_sort_routes(&mut cfg.model_routes);
701
702        if should_touch_rule {
703            if matches!(classification_enabled, Some(false)) {
704                cfg.query_classification
705                    .rules
706                    .retain(|rule| rule.hint != hint);
707            } else {
708                let existing_rule = cfg
709                    .query_classification
710                    .rules
711                    .iter()
712                    .find(|rule| rule.hint == hint)
713                    .cloned();
714
715                let mut next_rule = existing_rule.unwrap_or_else(|| ClassificationRule {
716                    hint: hint.clone(),
717                    ..ClassificationRule::default()
718                });
719
720                if let Some(keywords) = keywords_update {
721                    next_rule.keywords = keywords;
722                }
723                if let Some(patterns) = patterns_update {
724                    next_rule.patterns = patterns;
725                }
726
727                match min_length_update {
728                    MaybeSet::Set(value) => next_rule.min_length = Some(value),
729                    MaybeSet::Null => next_rule.min_length = None,
730                    MaybeSet::Unset => {}
731                }
732
733                match max_length_update {
734                    MaybeSet::Set(value) => next_rule.max_length = Some(value),
735                    MaybeSet::Null => next_rule.max_length = None,
736                    MaybeSet::Unset => {}
737                }
738
739                match priority_update {
740                    MaybeSet::Set(value) => next_rule.priority = value,
741                    MaybeSet::Null => next_rule.priority = 0,
742                    MaybeSet::Unset => {}
743                }
744
745                if matches!(classification_enabled, Some(true)) {
746                    Self::ensure_rule_defaults(&mut next_rule, &hint);
747                }
748
749                if !Self::has_rule_matcher(&next_rule) {
750                    anyhow::bail!(
751                        "Classification rule for hint '{hint}' has no matching criteria. Provide keywords/patterns or set min_length/max_length."
752                    );
753                }
754
755                cfg.query_classification
756                    .rules
757                    .retain(|rule| rule.hint != hint);
758                cfg.query_classification.rules.push(next_rule);
759            }
760        }
761
762        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
763        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
764
765        cfg.save().await?;
766
767        Ok(ToolResult {
768            success: true,
769            output: serde_json::to_string_pretty(&json!({
770                "message": "Scenario route upserted",
771                "hint": hint,
772                "config": Self::snapshot(&cfg),
773            }))?,
774            error: None,
775        })
776    }
777
778    async fn handle_remove_scenario(&self, args: &Value) -> anyhow::Result<ToolResult> {
779        let hint = Self::parse_non_empty_string(args, "hint")?;
780        let remove_classification = args
781            .get("remove_classification")
782            .and_then(Value::as_bool)
783            .unwrap_or(true);
784
785        let mut cfg = self.load_config_without_env()?;
786
787        let before_routes = cfg.model_routes.len();
788        cfg.model_routes.retain(|route| route.hint != hint);
789        let routes_removed = before_routes.saturating_sub(cfg.model_routes.len());
790
791        let mut rules_removed = 0usize;
792        if remove_classification {
793            let before_rules = cfg.query_classification.rules.len();
794            cfg.query_classification
795                .rules
796                .retain(|rule| rule.hint != hint);
797            rules_removed = before_rules.saturating_sub(cfg.query_classification.rules.len());
798        }
799
800        if routes_removed == 0 && rules_removed == 0 {
801            anyhow::bail!("No scenario found for hint '{hint}'");
802        }
803
804        Self::normalize_and_sort_routes(&mut cfg.model_routes);
805        Self::normalize_and_sort_rules(&mut cfg.query_classification.rules);
806        cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty();
807
808        cfg.save().await?;
809
810        Ok(ToolResult {
811            success: true,
812            output: serde_json::to_string_pretty(&json!({
813                "message": "Scenario removed",
814                "hint": hint,
815                "routes_removed": routes_removed,
816                "classification_rules_removed": rules_removed,
817                "config": Self::snapshot(&cfg),
818            }))?,
819            error: None,
820        })
821    }
822
823    async fn handle_upsert_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
824        let name = Self::parse_non_empty_string(args, "name")?;
825        let model_provider = Self::parse_non_empty_string(args, "model_provider")?;
826        let model = Self::parse_non_empty_string(args, "model")?;
827
828        let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
829        let temperature_update = Self::parse_optional_f64_update(args, "temperature")?;
830        let max_depth_update = Self::parse_optional_u32_update(args, "max_depth")?;
831        let max_iterations_update = Self::parse_optional_usize_update(args, "max_iterations")?;
832        let agentic_update = Self::parse_optional_bool(args, "agentic")?;
833
834        let allowed_tools_update = if let Some(raw) = args.get("allowed_tools") {
835            Some(Self::parse_string_list(raw, "allowed_tools")?)
836        } else {
837            None
838        };
839
840        let mut cfg = self.load_config_without_env()?;
841
842        // synthesize providers.models[model_provider_family][name] from inline brain params.
843        // The arg is the family name (e.g. "openai"); the agent's `model_provider`
844        // reference becomes the dotted form (e.g. "openai.coder").
845        let model_provider_family = model_provider;
846        let agent_model_provider_ref = format!("{model_provider_family}.{name}");
847        {
848            let provider_entry =
849                cfg.providers.models
850                    .ensure(&model_provider_family, &name)
851                    .ok_or_else(|| {
852                        ::zeroclaw_log::record!(
853                            ERROR,
854                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
855                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
856                                .with_attrs(::serde_json::json!({
857                                    "model_provider_family": &model_provider_family,
858                                    "name": &name,
859                                })),
860                            "model_routing_config: unknown model_provider family"
861                        );
862                        anyhow::Error::msg(format!(
863                            "unknown model_provider type `{model_provider_family}`. no typed slot in ModelProviders"
864                        ))
865                    })?;
866            provider_entry.model = Some(model.clone());
867            match api_key_update {
868                MaybeSet::Set(ref v) => provider_entry.api_key = Some(v.clone()),
869                MaybeSet::Null => provider_entry.api_key = None,
870                MaybeSet::Unset => {}
871            }
872            match temperature_update {
873                MaybeSet::Set(value) => {
874                    if !(0.0..=2.0).contains(&value) {
875                        anyhow::bail!("'temperature' must be between 0.0 and 2.0");
876                    }
877                    provider_entry.temperature = Some(value);
878                }
879                MaybeSet::Null => provider_entry.temperature = None,
880                MaybeSet::Unset => {}
881            }
882        }
883
884        // synthesize risk_profiles[name] from allowed_tools (authorization).
885        {
886            let risk = cfg.risk_profiles.entry(name.clone()).or_default();
887            if let Some(tools) = allowed_tools_update {
888                risk.allowed_tools = tools;
889            }
890        }
891
892        // synthesize runtime_profiles[name] from agentic/max_iterations/max_depth.
893        {
894            let runtime = cfg.runtime_profiles.entry(name.clone()).or_default();
895            if let Some(agentic) = agentic_update {
896                runtime.agentic = agentic;
897            }
898            if let MaybeSet::Set(iters) = max_iterations_update {
899                if iters == 0 {
900                    anyhow::bail!("'max_iterations' must be greater than 0");
901                }
902                runtime.max_tool_iterations = iters;
903            } else if runtime.max_tool_iterations == 0 {
904                runtime.max_tool_iterations = DEFAULT_AGENT_MAX_ITERATIONS;
905            }
906            if let MaybeSet::Set(depth) = max_depth_update {
907                if depth == 0 {
908                    anyhow::bail!("'max_depth' must be greater than 0");
909                }
910                runtime.max_delegation_depth = depth;
911            } else if runtime.max_delegation_depth == 0 {
912                runtime.max_delegation_depth = DEFAULT_AGENT_MAX_DEPTH;
913            }
914            if runtime.agentic {
915                let allowed_tools_empty = cfg
916                    .risk_profiles
917                    .get(&name)
918                    .is_none_or(|r| r.allowed_tools.is_empty());
919                if allowed_tools_empty {
920                    anyhow::bail!("Agent '{name}' has agentic=true but allowed_tools is empty.");
921                }
922            }
923        }
924
925        // Get or create the agent and wire up alias references.
926        let next_agent = cfg.agents.entry(name.clone()).or_default();
927        next_agent.model_provider = agent_model_provider_ref.into();
928        next_agent.risk_profile = name.clone();
929        next_agent.runtime_profile = name.clone();
930
931        cfg.save().await?;
932
933        Ok(ToolResult {
934            success: true,
935            output: serde_json::to_string_pretty(&json!({
936                "message": "Delegate agent upserted",
937                "name": name,
938                "config": Self::snapshot(&cfg),
939            }))?,
940            error: None,
941        })
942    }
943
944    async fn handle_remove_agent(&self, args: &Value) -> anyhow::Result<ToolResult> {
945        let name = Self::parse_non_empty_string(args, "name")?;
946
947        let mut cfg = self.load_config_without_env()?;
948        if cfg.agents.remove(&name).is_none() {
949            anyhow::bail!("No aliased agent found with name '{name}'");
950        }
951
952        cfg.save().await?;
953
954        Ok(ToolResult {
955            success: true,
956            output: serde_json::to_string_pretty(&json!({
957                "message": "Aliased agent removed",
958                "name": name,
959                "config": Self::snapshot(&cfg),
960            }))?,
961            error: None,
962        })
963    }
964}
965
966#[async_trait]
967impl Tool for ModelRoutingConfigTool {
968    fn name(&self) -> &str {
969        "model_routing_config"
970    }
971
972    fn description(&self) -> &str {
973        "Manage default model settings, scenario-based model_provider/model routes, classification rules, and aliased agent profiles"
974    }
975
976    fn parameters_schema(&self) -> Value {
977        json!({
978            "type": "object",
979            "properties": {
980                "action": {
981                    "type": "string",
982                    "enum": [
983                        "get",
984                        "list_hints",
985                        "set_default",
986                        "upsert_scenario",
987                        "remove_scenario",
988                        "upsert_agent",
989                        "remove_agent"
990                    ],
991                    "default": "get"
992                },
993                "hint": {
994                    "type": "string",
995                    "description": "Scenario hint name (for example: conversation, coding, reasoning)"
996                },
997                "model_provider": {
998                    "type": "string",
999                    "description": "ModelProvider for set_default/upsert_scenario/upsert_agent"
1000                },
1001                "model": {
1002                    "type": "string",
1003                    "description": "Model for set_default/upsert_scenario/upsert_agent"
1004                },
1005                "temperature": {
1006                    "type": ["number", "null"],
1007                    "description": "Optional temperature override (0.0-2.0)"
1008                },
1009                "api_key": {
1010                    "type": ["string", "null"],
1011                    "description": "Optional API key override for scenario route or aliased agent"
1012                },
1013                "keywords": {
1014                    "description": "Classification keywords for upsert_scenario (string or string array)",
1015                    "oneOf": [
1016                        {"type": "string"},
1017                        {"type": "array", "items": {"type": "string"}}
1018                    ]
1019                },
1020                "patterns": {
1021                    "description": "Classification literal patterns for upsert_scenario (string or string array)",
1022                    "oneOf": [
1023                        {"type": "string"},
1024                        {"type": "array", "items": {"type": "string"}}
1025                    ]
1026                },
1027                "min_length": {
1028                    "type": ["integer", "null"],
1029                    "minimum": 0,
1030                    "description": "Optional minimum message length matcher"
1031                },
1032                "max_length": {
1033                    "type": ["integer", "null"],
1034                    "minimum": 0,
1035                    "description": "Optional maximum message length matcher"
1036                },
1037                "priority": {
1038                    "type": ["integer", "null"],
1039                    "description": "Classification priority (higher runs first)"
1040                },
1041                "classification_enabled": {
1042                    "type": "boolean",
1043                    "description": "When true, upsert classification rule for this hint; false removes it"
1044                },
1045                "remove_classification": {
1046                    "type": "boolean",
1047                    "description": "When remove_scenario, whether to remove matching classification rule (default true)"
1048                },
1049                "name": {
1050                    "type": "string",
1051                    "description": "Aliased agent name for upsert_agent/remove_agent"
1052                },
1053                "max_depth": {
1054                    "type": ["integer", "null"],
1055                    "minimum": 1,
1056                    "description": "Delegate max recursion depth"
1057                },
1058                "agentic": {
1059                    "type": "boolean",
1060                    "description": "Enable tool-call loop mode for aliased agent"
1061                },
1062                "allowed_tools": {
1063                    "description": "Allowed tools for agentic delegate mode (string or string array)",
1064                    "oneOf": [
1065                        {"type": "string"},
1066                        {"type": "array", "items": {"type": "string"}}
1067                    ]
1068                },
1069                "max_iterations": {
1070                    "type": ["integer", "null"],
1071                    "minimum": 1,
1072                    "description": "Maximum tool-call iterations for agentic delegate mode"
1073                }
1074            },
1075            "additionalProperties": false
1076        })
1077    }
1078
1079    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1080        let action = args
1081            .get("action")
1082            .and_then(Value::as_str)
1083            .unwrap_or("get")
1084            .to_ascii_lowercase();
1085
1086        let result = match action.as_str() {
1087            "get" => self.handle_get(),
1088            "list_hints" => self.handle_list_hints(),
1089            "set_default" | "upsert_scenario" | "remove_scenario" | "upsert_agent"
1090            | "remove_agent" => {
1091                if let Some(blocked) = self.require_write_access() {
1092                    return Ok(blocked);
1093                }
1094
1095                match action.as_str() {
1096                    "set_default" => Box::pin(self.handle_set_default(&args)).await,
1097                    "upsert_scenario" => Box::pin(self.handle_upsert_scenario(&args)).await,
1098                    "remove_scenario" => Box::pin(self.handle_remove_scenario(&args)).await,
1099                    "upsert_agent" => Box::pin(self.handle_upsert_agent(&args)).await,
1100                    "remove_agent" => Box::pin(self.handle_remove_agent(&args)).await,
1101                    _ => unreachable!("validated above"),
1102                }
1103            }
1104            _ => anyhow::bail!(
1105                "Unknown action '{action}'. Valid: get, list_hints, set_default, upsert_scenario, remove_scenario, upsert_agent, remove_agent"
1106            ),
1107        };
1108
1109        match result {
1110            Ok(outcome) => Ok(outcome),
1111            Err(error) => Ok(ToolResult {
1112                success: false,
1113                output: String::new(),
1114                error: Some(error.to_string()),
1115            }),
1116        }
1117    }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123    use tempfile::TempDir;
1124    use zeroclaw_config::autonomy::AutonomyLevel;
1125    use zeroclaw_config::policy::SecurityPolicy;
1126
1127    fn test_security() -> Arc<SecurityPolicy> {
1128        Arc::new(SecurityPolicy {
1129            autonomy: AutonomyLevel::Supervised,
1130            workspace_dir: std::env::temp_dir(),
1131            ..SecurityPolicy::default()
1132        })
1133    }
1134
1135    fn readonly_security() -> Arc<SecurityPolicy> {
1136        Arc::new(SecurityPolicy {
1137            autonomy: AutonomyLevel::ReadOnly,
1138            workspace_dir: std::env::temp_dir(),
1139            ..SecurityPolicy::default()
1140        })
1141    }
1142
1143    async fn test_config(tmp: &TempDir) -> Arc<Config> {
1144        let config = Config {
1145            data_dir: tmp.path().join("data"),
1146            config_path: tmp.path().join("config.toml"),
1147            ..Config::default()
1148        };
1149        config.save().await.unwrap();
1150        Arc::new(config)
1151    }
1152
1153    #[tokio::test]
1154    async fn set_default_updates_provider_model_and_temperature() {
1155        let tmp = TempDir::new().unwrap();
1156        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1157
1158        let result = tool
1159            .execute(json!({
1160                "action": "set_default",
1161                "model_provider": "moonshot",
1162                "model": "moonshot-v1-8k",
1163                "temperature": 0.2
1164            }))
1165            .await
1166            .unwrap();
1167
1168        assert!(result.success, "{:?}", result.error);
1169        let output: Value = serde_json::from_str(&result.output).unwrap();
1170        assert_eq!(
1171            output["config"]["default"]["model_provider"].as_str(),
1172            Some("moonshot")
1173        );
1174        assert_eq!(
1175            output["config"]["default"]["model"].as_str(),
1176            Some("moonshot-v1-8k")
1177        );
1178        assert_eq!(
1179            output["config"]["default"]["temperature"].as_f64(),
1180            Some(0.2)
1181        );
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        // When no API key is configured (test_config has none), the probe is
1316        // skipped and any model string is accepted. This verifies the probe-
1317        // skip path doesn't accidentally reject valid config changes.
1318        let tmp = TempDir::new().unwrap();
1319        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1320
1321        let result = tool
1322            .execute(json!({
1323                "action": "set_default",
1324                "model_provider": "anthropic",
1325                "model": "totally-fake-model-12345"
1326            }))
1327            .await
1328            .unwrap();
1329
1330        assert!(result.success, "{:?}", result.error);
1331        let output: Value = serde_json::from_str(&result.output).unwrap();
1332        assert_eq!(
1333            output["config"]["default"]["model"].as_str(),
1334            Some("totally-fake-model-12345")
1335        );
1336    }
1337
1338    #[tokio::test]
1339    async fn set_default_temperature_only_skips_probe() {
1340        // Temperature-only changes don't set a new model, so the probe should
1341        // not fire at all (no model_provider/model to probe).
1342        let tmp = TempDir::new().unwrap();
1343        let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
1344
1345        let result = tool
1346            .execute(json!({
1347                "action": "set_default",
1348                "temperature": 1.5
1349            }))
1350            .await
1351            .unwrap();
1352
1353        assert!(result.success, "{:?}", result.error);
1354        let output: Value = serde_json::from_str(&result.output).unwrap();
1355        assert_eq!(
1356            output["config"]["default"]["temperature"].as_f64(),
1357            Some(1.5)
1358        );
1359    }
1360}