1use std::collections::HashMap;
4
5pub fn min_healthy_agents_threshold() -> usize {
8 crate::config::get("dispatch.min_healthy_agents")
9 .ok()
10 .and_then(|v| v.parse().ok())
11 .unwrap_or(2)
12}
13
14pub fn sequential_dispatch_delay_ms() -> u64 {
16 crate::config::get("dispatch.sequential_delay_ms")
17 .ok()
18 .and_then(|v| v.parse().ok())
19 .unwrap_or(1000)
20}
21
22pub fn health_check_window_hours() -> u32 {
27 crate::config::get("dispatch.health_check_window_hours")
28 .ok()
29 .and_then(|v| v.parse().ok())
30 .unwrap_or(crate::engine::cooldown::DEFAULT_HEALTH_CHECK_WINDOW_HOURS)
31}
32
33pub fn degraded_rate_limit_threshold() -> i64 {
47 crate::config::get("dispatch.degraded_rate_limit_threshold")
48 .ok()
49 .and_then(|v| v.parse().ok())
50 .unwrap_or(crate::engine::cooldown::DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD)
51}
52
53pub fn retry_base_delay_ms() -> u64 {
55 crate::config::get("dispatch.retry_base_delay_ms")
56 .ok()
57 .and_then(|v| v.parse().ok())
58 .unwrap_or(10_000)
59}
60
61pub fn retry_max_delay_ms() -> u64 {
63 crate::config::get("dispatch.retry_max_delay_ms")
64 .ok()
65 .and_then(|v| v.parse().ok())
66 .unwrap_or(120_000)
67}
68
69pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
74
75pub fn get_agent_backoff_base(agent: &str) -> i64 {
84 let key = format!("router.backoff_base.{agent}");
85 crate::config::get(&key)
86 .ok()
87 .and_then(|v| v.parse().ok())
88 .unwrap_or(crate::engine::cooldown::BACKOFF_BASE_SECS)
89}
90
91pub fn max_tasks_per_routing_tick() -> usize {
98 crate::config::get("router.max_tasks_per_tick")
99 .ok()
100 .and_then(|v| v.parse().ok())
101 .unwrap_or(1)
102}
103
104pub const DEFAULT_AGENTS: &[&str] = &[
111 "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
112];
113
114#[derive(Debug, Clone)]
116pub struct RouterConfig {
117 pub mode: String,
123 pub router_agent: String,
125 pub router_model: String,
127 pub timeout_seconds: u64,
129 pub fallback_executor: String,
131 pub agents: Vec<String>,
133 pub max_route_attempts: u32,
135 pub allowed_tools: Vec<String>,
137 pub default_skills: Vec<String>,
139 pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
143 pub weighted_round_robin: bool,
146 pub pool: Vec<String>,
150 pub fallback: String,
153 pub self_routing_penalty: f64,
161 pub weights: HashMap<String, f64>,
179 pub skip_limited_threshold: f64,
184 pub agent_backoff_bases: HashMap<String, i64>,
191 pub max_tasks_per_tick: usize,
198 pub ollama_url: String,
200 pub ollama_model: String,
202 pub ollama_timeout_seconds: u64,
204}
205
206pub fn parse_pool_entry(entry: &str) -> (String, String) {
213 if let Some(colon_pos) = entry.find(':') {
214 let agent = entry[..colon_pos].to_string();
215 let model = entry[colon_pos + 1..].to_string();
216 let model = RouterConfig::normalize_model_identifier(&model);
218 (agent, model)
219 } else {
220 (entry.to_string(), String::new())
221 }
222}
223
224impl Default for RouterConfig {
225 fn default() -> Self {
226 Self {
227 mode: "round_robin".to_string(),
228 router_agent: "claude".to_string(),
229 router_model: "haiku".to_string(),
230 timeout_seconds: 45,
231 fallback_executor: "codex".to_string(),
232 agents: DEFAULT_AGENTS.iter().map(|s| s.to_string()).collect(),
233 max_route_attempts: 3,
234 pool: vec![],
235 fallback: String::new(),
236 self_routing_penalty: 1.0,
237 weights: HashMap::new(),
238 allowed_tools: vec![
239 "yq".to_string(),
240 "jq".to_string(),
241 "bash".to_string(),
242 "just".to_string(),
243 "git".to_string(),
244 "rg".to_string(),
245 "sed".to_string(),
246 "awk".to_string(),
247 "python3".to_string(),
248 "node".to_string(),
249 "npm".to_string(),
250 "bun".to_string(),
251 ],
252 default_skills: vec!["gh".to_string(), "git-worktree".to_string()],
253 model_map: HashMap::new(),
257 weighted_round_robin: false,
258 skip_limited_threshold: 0.3,
259 agent_backoff_bases: HashMap::new(),
260 max_tasks_per_tick: 1,
261 ollama_url: "http://localhost:11434".to_string(),
262 ollama_model: "qwen2.5-coder:3b-instruct".to_string(),
263 ollama_timeout_seconds: 30,
264 }
265 }
266}
267
268impl RouterConfig {
269 pub fn from_config() -> Self {
271 let mut config = Self::default();
272
273 if let Ok(mode) = crate::config::get("router.mode") {
275 if mode == "round_robin" || mode == "llm" || mode == "local" {
276 config.mode = mode;
277 }
278 }
279
280 if let Ok(agent) = crate::config::get("router.agent") {
281 if !agent.is_empty() {
282 config.router_agent = agent;
283 }
284 }
285
286 if let Ok(model) = crate::config::get("router.model") {
287 if !model.is_empty() {
288 config.router_model = RouterConfig::normalize_model_identifier(&model);
289 }
290 }
291
292 if let Ok(timeout) = crate::config::get("router.timeout_seconds") {
293 if let Ok(secs) = timeout.parse::<u64>() {
294 let clamped = secs.min(MAX_ROUTER_TIMEOUT_SECS);
295 if clamped != secs {
296 static TIMEOUT_CLAMP_WARNED: std::sync::OnceLock<()> =
297 std::sync::OnceLock::new();
298 TIMEOUT_CLAMP_WARNED.get_or_init(|| {
299 tracing::warn!(
300 configured_secs = secs,
301 applied_secs = clamped,
302 max_secs = MAX_ROUTER_TIMEOUT_SECS,
303 "router.timeout_seconds is too high; clamping to keep routing responsive"
304 );
305 });
306 }
307 config.timeout_seconds = clamped;
308 }
309 }
310
311 if let Ok(fallback) = crate::config::get("router.fallback_executor") {
312 if !fallback.is_empty() {
313 config.fallback_executor = fallback;
314 }
315 }
316
317 config.agents = crate::engine::configured_agents();
319
320 let exclude_agents = std::env::var("ORCH_EXCLUDE_AGENTS")
326 .map(|v| {
327 v.split(',')
328 .map(|s| s.trim().to_string())
329 .filter(|s| !s.is_empty())
330 .collect::<Vec<_>>()
331 })
332 .unwrap_or_default();
333 if !exclude_agents.is_empty() {
334 let original_len = config.agents.len();
335 config.agents.retain(|a| !exclude_agents.contains(a));
336 let removed_count = original_len - config.agents.len();
337 if removed_count > 0 {
338 tracing::warn!(
339 "ORCH_EXCLUDE_AGENTS={}: temporarily removing {} agent(s) from router",
340 exclude_agents.join(","),
341 removed_count
342 );
343 }
344 }
345
346 if let Ok(max_attempts) = crate::config::get("router.max_route_attempts") {
347 if let Ok(n) = max_attempts.parse::<u32>() {
348 config.max_route_attempts = n;
349 }
350 }
351
352 if let Ok(tools_str) = crate::config::get("router.allowed_tools") {
354 if !tools_str.is_empty() && tools_str != "[]" {
355 if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
357 config.allowed_tools = tools_arr;
358 } else {
359 config.allowed_tools = tools_str
361 .split(',')
362 .map(|s| s.trim().to_string())
363 .filter(|s| !s.is_empty())
364 .collect();
365 }
366 }
367 }
368
369 if let Ok(val) = crate::config::get("router.weighted_round_robin") {
371 config.weighted_round_robin = val == "true" || val == "1";
372 }
373
374 if let Ok(val) = crate::config::get("router.self_routing_penalty") {
376 if let Ok(penalty) = val.parse::<f64>() {
377 config.self_routing_penalty = penalty.clamp(0.0, 1.0);
378 }
379 }
380
381 for agent in &config.agents {
383 let key = format!("router.weights.{agent}");
384 if let Ok(val) = crate::config::get(&key) {
385 if let Ok(w) = val.parse::<f64>() {
386 config.weights.insert(agent.clone(), w.max(0.0));
387 }
388 }
389 }
390
391 for agent in &config.agents {
393 let key = format!("router.backoff_base.{agent}");
394 if let Ok(val) = crate::config::get(&key) {
395 if let Ok(secs) = val.parse::<i64>() {
396 config.agent_backoff_bases.insert(agent.clone(), secs);
397 }
398 }
399 }
400
401 if let Ok(val) = crate::config::get("router.max_tasks_per_tick") {
403 if let Ok(n) = val.parse::<usize>() {
404 if n > 0 {
405 config.max_tasks_per_tick = n;
406 }
407 }
408 }
409
410 match crate::config::get_list("router.pool") {
414 Ok(pool_list) if !pool_list.is_empty() => {
415 config.pool = pool_list;
416 }
417 _ => {
418 if let Ok(pool_raw) = crate::config::get("router.pool") {
420 let parsed = pool_raw
421 .split(',')
422 .map(|s| s.trim().to_string())
423 .filter(|s| !s.is_empty())
424 .collect::<Vec<_>>();
425 if !parsed.is_empty() {
426 config.pool = parsed;
427 }
428 }
429 }
430 }
431
432 if let Ok(fallback) = crate::config::get("router.fallback") {
434 if !fallback.is_empty() {
435 config.fallback = fallback;
436 }
437 }
438
439 let known_agents = crate::engine::configured_agents();
445 let known_complexities = ["simple", "medium", "complex", "review"];
446 for agent in &known_agents {
447 let key = format!("models.{agent}");
448 if let Some(models) = Self::read_model_pool_from_config(&key) {
449 for complexity in known_complexities {
450 config
451 .model_map
452 .entry(complexity.to_string())
453 .or_default()
454 .insert(agent.to_string(), models.clone());
455 }
456 }
457 }
458
459 for complexity in known_complexities {
464 for agent in &known_agents {
465 let key = format!("model_map.{complexity}.{agent}");
466 if let Some(models) = Self::read_model_pool_from_config(&key) {
467 config
468 .model_map
469 .entry(complexity.to_string())
470 .or_default()
471 .insert(agent.to_string(), models);
472 }
473 }
474 }
475
476 let discovered_opencode =
482 crate::engine::runner::agents::opencode::discover_opencode_models();
483 if !discovered_opencode.is_empty() {
484 let discovered: std::collections::HashSet<String> =
485 discovered_opencode.into_iter().collect();
486 for complexity in known_complexities {
487 if let Some(agent_models) = config.model_map.get(complexity) {
488 if let Some(models) = agent_models.get("opencode") {
489 for model in models {
490 if model != "opencode:free" && !discovered.contains(model) {
491 tracing::warn!(
492 complexity,
493 model,
494 "opencode model from config not present in provider model list; pruning from dispatch pool"
495 );
496 }
497 }
498 }
499 }
500 }
501 let pruned =
502 Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
503 if pruned > 0 {
504 tracing::info!(
505 pruned_count = pruned,
506 "model_pruned_unavailable: removed opencode models absent from provider catalog"
507 );
508 }
509 }
510
511 if let Ok(skills_str) = crate::config::get("router.default_skills") {
513 if !skills_str.is_empty() && skills_str != "[]" {
514 if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
515 config.default_skills = skills_arr;
516 } else {
517 config.default_skills = skills_str
518 .split(',')
519 .map(|s| s.trim().to_string())
520 .filter(|s| !s.is_empty())
521 .collect();
522 }
523 }
524 }
525
526 if let Ok(url) = crate::config::get("router.ollama_url") {
528 if !url.is_empty() {
529 config.ollama_url = url;
530 }
531 }
532 if let Ok(model) = crate::config::get("router.ollama_model") {
533 if !model.is_empty() {
534 config.ollama_model = model;
535 }
536 }
537 if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds") {
538 if let Ok(secs) = timeout.parse::<u64>() {
539 if secs > 0 {
540 config.ollama_timeout_seconds = secs;
541 }
542 }
543 }
544
545 config
546 }
547
548 pub fn effective_pool(&self) -> Vec<String> {
550 if !self.pool.is_empty() {
551 self.pool.clone()
552 } else {
553 vec![format!("{}:{}", self.router_agent, self.router_model)]
554 }
555 }
556
557 pub fn effective_fallback(&self) -> String {
559 if !self.fallback.is_empty() {
560 self.fallback.clone()
561 } else {
562 format!("{}:{}", self.router_agent, self.router_model)
563 }
564 }
565
566 fn is_valid_model_identifier(model: &str) -> bool {
569 if model.is_empty() {
570 return false;
571 }
572 if model.ends_with('/') {
574 return false;
575 }
576 if model.chars().any(char::is_whitespace) {
578 return false;
579 }
580 if let Some(slash_pos) = model.find('/') {
582 let before = &model[..slash_pos];
583 let after = &model[slash_pos + 1..];
584 if before.is_empty() || after.is_empty() {
585 return false;
586 }
587 }
588 true
589 }
590
591 pub fn normalize_model_identifier(model: &str) -> String {
595 model.trim_end_matches('/').to_string()
596 }
597
598 fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
599 if let Ok(list) = crate::config::get_list(key) {
600 if !list.is_empty() {
601 return Some(
602 list.iter()
603 .map(|m| RouterConfig::normalize_model_identifier(m))
604 .collect(),
605 );
606 }
607 } else if let Ok(val) = crate::config::get(key) {
608 if !val.is_empty() {
609 return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
610 }
611 }
612
613 None
614 }
615
616 fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
617 let pool = self.model_map.get(complexity)?.get(agent)?;
618 if pool.is_empty() {
619 return Some(Vec::new());
620 }
621
622 let mut expanded_pool = Vec::new();
623 let mut has_free = false;
624
625 for model in pool {
626 let normalized = Self::normalize_model_identifier(model);
628 if normalized == "opencode:free" {
629 has_free = true;
630 } else if Self::is_valid_model_identifier(&normalized) {
631 expanded_pool.push(normalized);
632 } else {
633 tracing::debug!(agent, model, "skipping invalid model identifier in config");
634 }
635 }
636
637 if has_free {
638 expanded_pool
639 .extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
640 }
641
642 expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
644
645 if agent == "opencode" {
648 let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
649 if !discovered.is_empty() {
650 let discovered_set: std::collections::HashSet<String> =
651 discovered.into_iter().collect();
652 expanded_pool.retain(|m| discovered_set.contains(m));
653 }
654 }
655
656 Some(expanded_pool)
657 }
658
659 pub(crate) fn model_pool_for_complexity(
660 &self,
661 agent: &str,
662 complexity: &str,
663 ) -> Option<Vec<String>> {
664 self.expanded_model_pool(agent, complexity)
665 }
666
667 pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
668 match self.expanded_model_pool(agent, complexity) {
669 None => false,
670 Some(pool) => pool
671 .iter()
672 .any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
673 }
674 }
675
676 pub fn model_for_complexity(
684 &self,
685 agent: &str,
686 complexity: &str,
687 task_id: &str,
688 ) -> Option<String> {
689 let expanded_pool = self.expanded_model_pool(agent, complexity)?;
690 if expanded_pool.is_empty() {
691 return None;
692 }
693 let start =
695 crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
696 for i in 0..expanded_pool.len() {
698 let model = &expanded_pool[(start + i) % expanded_pool.len()];
699 if !Self::is_valid_model_identifier(model) {
701 tracing::debug!(
702 agent,
703 model,
704 "invalid model identifier slipped through validation"
705 );
706 continue;
707 }
708 if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
709 return Some(Self::normalize_model_identifier(model));
711 }
712 }
713 None
715 }
716
717 fn prune_unavailable_opencode_models(
723 model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
724 discovered: &std::collections::HashSet<String>,
725 ) -> usize {
726 let mut pruned = 0;
727 for agent_models in model_map.values_mut() {
728 if let Some(models) = agent_models.get_mut("opencode") {
729 let before = models.len();
730 models.retain(|m| m == "opencode:free" || discovered.contains(m));
731 pruned += before - models.len();
732 }
733 }
734 pruned
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::{RouterConfig, MAX_ROUTER_TIMEOUT_SECS};
741 use serial_test::serial;
742 use std::sync::{Mutex, OnceLock};
743
744 struct CurrentDirGuard {
745 original: std::path::PathBuf,
746 }
747
748 impl CurrentDirGuard {
749 fn set(path: &std::path::Path) -> Self {
750 let original = std::env::current_dir().unwrap();
751 std::env::set_current_dir(path).unwrap();
752 Self { original }
753 }
754 }
755
756 impl Drop for CurrentDirGuard {
757 fn drop(&mut self) {
758 std::env::set_current_dir(&self.original).unwrap();
759 }
760 }
761
762 fn cwd_mutex() -> &'static Mutex<()> {
763 static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
764 CWD_MUTEX.get_or_init(|| Mutex::new(()))
765 }
766
767 #[serial(cooldown_state)]
768 #[tokio::test]
769 async fn model_for_complexity_returns_none_when_all_models_cooled() {
770 crate::engine::cooldown::reset_global_state().await;
771 let mut config = RouterConfig::default();
772 config
774 .model_map
775 .entry("complex".to_string())
776 .or_default()
777 .insert(
778 "testagent_allcooled".to_string(),
779 vec![
780 "cooldown-model-a".to_string(),
781 "cooldown-model-b".to_string(),
782 ],
783 );
784
785 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
787 .await;
788 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
789 .await;
790
791 let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
792 assert!(
793 result.is_none(),
794 "should return None when all models in pool are in cooldown, got {result:?}"
795 );
796 }
797
798 #[serial(cooldown_state)]
799 #[tokio::test]
800 async fn model_for_complexity_returns_none_when_single_model_cooled() {
801 crate::engine::cooldown::reset_global_state().await;
802 let mut config = RouterConfig::default();
803 config
804 .model_map
805 .entry("complex".to_string())
806 .or_default()
807 .insert(
808 "testagent_singlecooled".to_string(),
809 vec!["single-cooled-model".to_string()],
810 );
811
812 crate::engine::cooldown::record_model_failure(
813 "testagent_singlecooled",
814 "single-cooled-model",
815 )
816 .await;
817
818 let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
819 assert!(
820 result.is_none(),
821 "should return None when the only model in pool is in cooldown, got {result:?}"
822 );
823 }
824
825 #[serial(cooldown_state)]
826 #[test]
827 fn model_for_complexity_returns_model_when_not_cooled() {
828 let mut config = RouterConfig::default();
829 config
830 .model_map
831 .entry("complex".to_string())
832 .or_default()
833 .insert(
834 "testagent_notcooled".to_string(),
835 vec!["healthy-model".to_string()],
836 );
837
838 let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
840 assert_eq!(
841 result.as_deref(),
842 Some("healthy-model"),
843 "should return the model when it is not in cooldown"
844 );
845 }
846
847 #[serial(cooldown_state)]
848 #[test]
849 fn has_available_model_returns_false_when_no_pool_configured() {
850 let mut config = RouterConfig::default();
851 config
853 .model_map
854 .entry("simple".to_string())
855 .or_default()
856 .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
857 config
858 .model_map
859 .entry("medium".to_string())
860 .or_default()
861 .insert("olm".to_string(), vec!["gemma4".to_string()]);
862 assert!(
866 config.has_available_model_for_complexity("olm", "simple"),
867 "should return true when model pool exists and is configured"
868 );
869 assert!(
870 config.has_available_model_for_complexity("olm", "medium"),
871 "should return true when model pool exists and is configured"
872 );
873 assert!(
874 !config.has_available_model_for_complexity("olm", "complex"),
875 "should return false when no model pool is configured for agent+complexity"
876 );
877 assert!(
878 !config.has_available_model_for_complexity("olm", "review"),
879 "should return false when no model pool is configured for agent+complexity"
880 );
881 }
882
883 #[serial(cooldown_state)]
884 #[test]
885 fn default_self_routing_penalty_is_one() {
886 let config = RouterConfig::default();
887 assert_eq!(
888 config.self_routing_penalty, 1.0,
889 "default penalty must be 1.0 (no penalty = full self-routing allowed)"
890 );
891 }
892
893 #[serial(cooldown_state)]
894 #[test]
895 fn from_config_reads_self_routing_penalty() {
896 let _lock = cwd_mutex().lock().unwrap();
897 let dir = tempfile::tempdir().unwrap();
898 std::fs::write(
899 dir.path().join(".orch.yml"),
900 "router:\n self_routing_penalty: 0.5\n",
901 )
902 .unwrap();
903 let _guard = CurrentDirGuard::set(dir.path());
904
905 let config = RouterConfig::from_config();
906 assert_eq!(config.self_routing_penalty, 0.5);
907 }
908
909 #[serial(cooldown_state)]
910 #[test]
911 fn default_config_model_map_is_empty() {
912 let config = RouterConfig::default();
916 assert!(
917 config.model_map.is_empty(),
918 "Default RouterConfig must have no hardcoded models; got {:?}",
919 config.model_map
920 );
921 }
922
923 #[serial(cooldown_state)]
924 #[test]
925 fn from_config_reads_router_pool_yaml_array() {
926 let _lock = cwd_mutex().lock().unwrap();
927 let dir = tempfile::tempdir().unwrap();
928 std::fs::write(
929 dir.path().join(".orch.yml"),
930 "router:\n pool:\n - opencode:free\n - opencode:github-copilot/gpt-5-mini\n - kimi:k2p5\n - claude:haiku\n",
931 )
932 .unwrap();
933 let _guard = CurrentDirGuard::set(dir.path());
934
935 let config = RouterConfig::from_config();
936
937 assert_eq!(
938 config.pool,
939 vec![
940 "opencode:free",
941 "opencode:github-copilot/gpt-5-mini",
942 "kimi:k2p5",
943 "claude:haiku",
944 ]
945 );
946 }
947
948 #[serial(cooldown_state)]
949 #[test]
950 fn from_config_clamps_router_timeout_to_max() {
951 let _lock = cwd_mutex().lock().unwrap();
952 let dir = tempfile::tempdir().unwrap();
953 std::fs::write(
954 dir.path().join(".orch.yml"),
955 "router:\n timeout_seconds: 90\n",
956 )
957 .unwrap();
958 let _guard = CurrentDirGuard::set(dir.path());
959
960 let config = RouterConfig::from_config();
961 assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
962 }
963
964 #[serial(cooldown_state)]
965 #[test]
966 fn normalize_model_identifier_strips_trailing_slash() {
967 assert_eq!(
969 RouterConfig::normalize_model_identifier("opus/"),
970 "opus",
971 "trailing slash should be stripped from model identifier"
972 );
973 assert_eq!(
974 RouterConfig::normalize_model_identifier("sonnet/"),
975 "sonnet",
976 "trailing slash should be stripped from model identifier"
977 );
978 assert_eq!(
979 RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
980 "github-copilot/gpt-5-mini",
981 "trailing slash should be stripped from model identifier with internal slash"
982 );
983 assert_eq!(
985 RouterConfig::normalize_model_identifier("opus//"),
986 "opus",
987 "multiple trailing slashes should be stripped"
988 );
989 assert_eq!(
991 RouterConfig::normalize_model_identifier("opus"),
992 "opus",
993 "model without trailing slash should remain unchanged"
994 );
995 }
996
997 #[serial(cooldown_state)]
998 #[test]
999 fn is_valid_model_identifier_rejects_trailing_slash() {
1000 assert!(
1002 !RouterConfig::is_valid_model_identifier("opus/"),
1003 "model with trailing slash should be invalid"
1004 );
1005 assert!(
1006 RouterConfig::is_valid_model_identifier("opus"),
1007 "model without trailing slash should be valid"
1008 );
1009 assert!(
1010 RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1011 "model with internal slash should be valid"
1012 );
1013 assert!(
1014 !RouterConfig::is_valid_model_identifier("github-copilot/"),
1015 "model ending with slash after prefix should be invalid"
1016 );
1017 }
1018
1019 #[serial(cooldown_state)]
1020 #[test]
1021 fn from_config_loads_model_map_for_custom_agents() {
1022 let _lock = cwd_mutex().lock().unwrap();
1023 let dir = tempfile::tempdir().unwrap();
1024 std::fs::write(
1026 dir.path().join(".orch.yml"),
1027 r#"
1028agents:
1029 - claude
1030 - olm
1031model_map:
1032 simple:
1033 claude: haiku
1034 olm: qwen3.5
1035 medium:
1036 claude: sonnet
1037 olm: gemma4
1038 complex:
1039 claude: opus
1040 olm: gemma4
1041"#,
1042 )
1043 .unwrap();
1044 let _guard = CurrentDirGuard::set(dir.path());
1045
1046 let config = RouterConfig::from_config();
1047
1048 assert_eq!(
1050 config.model_for_complexity("olm", "simple", "test-task"),
1051 Some("qwen3.5".to_string()),
1052 "custom agent olm should have simple model from config"
1053 );
1054 assert_eq!(
1055 config.model_for_complexity("olm", "medium", "test-task"),
1056 Some("gemma4".to_string()),
1057 "custom agent olm should have medium model from config"
1058 );
1059 assert_eq!(
1061 config.model_for_complexity("claude", "simple", "test-task"),
1062 Some("haiku".to_string()),
1063 );
1064 }
1065
1066 #[serial(cooldown_state)]
1067 #[test]
1068 fn from_config_loads_models_shorthand_for_all_complexities() {
1069 let _lock = cwd_mutex().lock().unwrap();
1070 let dir = tempfile::tempdir().unwrap();
1071 std::fs::write(
1072 dir.path().join(".orch.yml"),
1073 r#"
1074agents:
1075 - shorthand
1076models:
1077 shorthand:
1078 - gpt-5.2
1079 - gpt-5.5
1080"#,
1081 )
1082 .unwrap();
1083 let _guard = CurrentDirGuard::set(dir.path());
1084
1085 let config = RouterConfig::from_config();
1086
1087 for complexity in ["simple", "medium", "complex", "review"] {
1088 assert_eq!(
1089 config.model_map[complexity]["shorthand"],
1090 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1091 "models.shorthand should populate {complexity}"
1092 );
1093 }
1094 }
1095
1096 #[serial(cooldown_state)]
1097 #[test]
1098 fn from_config_loads_single_model_shorthand_for_all_complexities() {
1099 let _lock = cwd_mutex().lock().unwrap();
1100 let dir = tempfile::tempdir().unwrap();
1101 std::fs::write(
1102 dir.path().join(".orch.yml"),
1103 r#"
1104agents:
1105 - shorthand
1106models:
1107 shorthand: gpt-5.2
1108"#,
1109 )
1110 .unwrap();
1111 let _guard = CurrentDirGuard::set(dir.path());
1112
1113 let config = RouterConfig::from_config();
1114
1115 for complexity in ["simple", "medium", "complex", "review"] {
1116 assert_eq!(
1117 config.model_map[complexity]["shorthand"],
1118 vec!["gpt-5.2".to_string()],
1119 "models.shorthand should populate {complexity}"
1120 );
1121 }
1122 }
1123
1124 #[serial(cooldown_state)]
1125 #[test]
1126 fn from_config_model_map_overrides_models_shorthand_per_tier() {
1127 let _lock = cwd_mutex().lock().unwrap();
1128 let dir = tempfile::tempdir().unwrap();
1129 std::fs::write(
1130 dir.path().join(".orch.yml"),
1131 r#"
1132agents:
1133 - shorthand
1134models:
1135 shorthand:
1136 - gpt-5.2
1137 - gpt-5.5
1138model_map:
1139 review:
1140 shorthand: gpt-5.6-review
1141"#,
1142 )
1143 .unwrap();
1144 let _guard = CurrentDirGuard::set(dir.path());
1145
1146 let config = RouterConfig::from_config();
1147
1148 assert_eq!(
1149 config.model_map["simple"]["shorthand"],
1150 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1151 );
1152 assert_eq!(
1153 config.model_map["medium"]["shorthand"],
1154 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1155 );
1156 assert_eq!(
1157 config.model_map["complex"]["shorthand"],
1158 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1159 );
1160 assert_eq!(
1161 config.model_map["review"]["shorthand"],
1162 vec!["gpt-5.6-review".to_string()]
1163 );
1164 }
1165
1166 #[serial(cooldown_state)]
1167 #[test]
1168 fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1169 let mut config = RouterConfig::default();
1172 config
1173 .model_map
1174 .entry("complex".to_string())
1175 .or_default()
1176 .insert(
1177 "testagent_trailing".to_string(),
1178 vec![
1179 "opus/".to_string(),
1180 "sonnet//".to_string(),
1181 "github-copilot/gpt-5-mini/".to_string(),
1182 ],
1183 );
1184
1185 let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1187 assert!(
1188 result.is_some(),
1189 "should return a model from pool with trailing slashes normalized, got None"
1190 );
1191 let model = result.unwrap();
1192 assert!(
1194 model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1195 "returned model should be normalized (no trailing slash), got: {}",
1196 model
1197 );
1198 assert!(
1200 !model.ends_with('/'),
1201 "returned model should not have trailing slash, got: {}",
1202 model
1203 );
1204 }
1205
1206 #[serial(cooldown_state)]
1209 #[test]
1210 fn prune_unavailable_opencode_models_removes_missing_models() {
1211 use std::collections::{HashMap, HashSet};
1212
1213 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1214 model_map.entry("complex".to_string()).or_default().insert(
1215 "opencode".to_string(),
1216 vec![
1217 "available-model".to_string(),
1218 "unavailable-model".to_string(),
1219 "opencode:free".to_string(), ],
1221 );
1222
1223 let mut discovered = HashSet::new();
1224 discovered.insert("available-model".to_string());
1225 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1228
1229 assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1230
1231 let remaining = model_map["complex"]["opencode"].as_slice();
1232 assert!(
1233 remaining.contains(&"available-model".to_string()),
1234 "available model should remain"
1235 );
1236 assert!(
1237 remaining.contains(&"opencode:free".to_string()),
1238 "opencode:free sentinel should never be pruned"
1239 );
1240 assert!(
1241 !remaining.contains(&"unavailable-model".to_string()),
1242 "unavailable model should be removed"
1243 );
1244 }
1245
1246 #[serial(cooldown_state)]
1247 #[test]
1248 fn prune_unavailable_opencode_models_noop_when_all_available() {
1249 use std::collections::{HashMap, HashSet};
1250
1251 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1252 model_map.entry("simple".to_string()).or_default().insert(
1253 "opencode".to_string(),
1254 vec!["model-a".to_string(), "model-b".to_string()],
1255 );
1256
1257 let discovered: HashSet<String> = ["model-a", "model-b"]
1258 .iter()
1259 .map(|s| s.to_string())
1260 .collect();
1261
1262 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1263
1264 assert_eq!(pruned, 0, "nothing to prune when all models are available");
1265 assert_eq!(
1266 model_map["simple"]["opencode"].len(),
1267 2,
1268 "both models should remain"
1269 );
1270 }
1271
1272 #[serial(cooldown_state)]
1273 #[test]
1274 fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1275 use std::collections::{HashMap, HashSet};
1276
1277 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1278 model_map
1279 .entry("complex".to_string())
1280 .or_default()
1281 .insert("claude".to_string(), vec!["some-model".to_string()]);
1282
1283 let discovered: HashSet<String> = HashSet::new();
1285
1286 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1287
1288 assert_eq!(pruned, 0, "non-opencode agents must not be touched");
1289 assert_eq!(
1290 model_map["complex"]["claude"].len(),
1291 1,
1292 "claude model should be unchanged"
1293 );
1294 }
1295
1296 #[serial(cooldown_state)]
1297 #[test]
1298 fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
1299 use std::collections::{HashMap, HashSet};
1300
1301 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1302 for tier in ["simple", "medium", "complex", "review"] {
1303 model_map
1304 .entry(tier.to_string())
1305 .or_default()
1306 .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1307 }
1308
1309 let discovered: HashSet<String> = HashSet::new();
1311
1312 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1313
1314 assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1315 for tier in ["simple", "medium", "complex", "review"] {
1316 assert!(
1317 model_map[tier]["opencode"].is_empty(),
1318 "all entries for {} should be removed",
1319 tier
1320 );
1321 }
1322 }
1323}