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 let previous_first_model_provider = cfg.first_model_provider().cloned();
498
499 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 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 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 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 ::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 async fn probe_model(&self, provider_name: &str, model: &str) -> anyhow::Result<()> {
610 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 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 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 {
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 {
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 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 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();
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 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}