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