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 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 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 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 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 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 ::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 async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
609 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 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 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 {
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 {
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 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 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}