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 = if config.uses_opencode() {
484 crate::engine::runner::agents::opencode::discover_opencode_models()
485 } else {
486 Vec::new()
487 };
488 if !discovered_opencode.is_empty() {
489 let discovered: std::collections::HashSet<String> =
490 discovered_opencode.into_iter().collect();
491 for complexity in known_complexities {
492 if let Some(agent_models) = config.model_map.get(complexity) {
493 if let Some(models) = agent_models.get("opencode") {
494 for model in models {
495 if model != "opencode:free" && !discovered.contains(model) {
496 tracing::warn!(
497 complexity,
498 model,
499 "opencode model from config not present in provider model list; pruning from dispatch pool"
500 );
501 }
502 }
503 }
504 }
505 }
506 let pruned =
507 Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
508 if pruned > 0 {
509 tracing::info!(
510 pruned_count = pruned,
511 "model_pruned_unavailable: removed opencode models absent from provider catalog"
512 );
513 }
514 }
515
516 if let Ok(skills_str) = crate::config::get("router.default_skills") {
518 if !skills_str.is_empty() && skills_str != "[]" {
519 if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
520 config.default_skills = skills_arr;
521 } else {
522 config.default_skills = skills_str
523 .split(',')
524 .map(|s| s.trim().to_string())
525 .filter(|s| !s.is_empty())
526 .collect();
527 }
528 }
529 }
530
531 if let Ok(url) = crate::config::get("router.ollama_url") {
533 if !url.is_empty() {
534 config.ollama_url = url;
535 }
536 }
537 if let Ok(model) = crate::config::get("router.ollama_model") {
538 if !model.is_empty() {
539 config.ollama_model = model;
540 }
541 }
542 if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds") {
543 if let Ok(secs) = timeout.parse::<u64>() {
544 if secs > 0 {
545 config.ollama_timeout_seconds = secs;
546 }
547 }
548 }
549
550 config
551 }
552
553 pub fn effective_pool(&self) -> Vec<String> {
555 if !self.pool.is_empty() {
556 self.pool.clone()
557 } else {
558 vec![format!("{}:{}", self.router_agent, self.router_model)]
559 }
560 }
561
562 pub fn effective_fallback(&self) -> String {
564 if !self.fallback.is_empty() {
565 self.fallback.clone()
566 } else {
567 format!("{}:{}", self.router_agent, self.router_model)
568 }
569 }
570
571 fn is_valid_model_identifier(model: &str) -> bool {
574 if model.is_empty() {
575 return false;
576 }
577 if model.ends_with('/') {
579 return false;
580 }
581 if model.chars().any(char::is_whitespace) {
583 return false;
584 }
585 if let Some(slash_pos) = model.find('/') {
587 let before = &model[..slash_pos];
588 let after = &model[slash_pos + 1..];
589 if before.is_empty() || after.is_empty() {
590 return false;
591 }
592 }
593 true
594 }
595
596 pub fn normalize_model_identifier(model: &str) -> String {
600 model.trim_end_matches('/').to_string()
601 }
602
603 fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
604 if let Ok(list) = crate::config::get_list(key) {
605 if !list.is_empty() {
606 return Some(
607 list.iter()
608 .map(|m| RouterConfig::normalize_model_identifier(m))
609 .collect(),
610 );
611 }
612 } else if let Ok(val) = crate::config::get(key) {
613 if !val.is_empty() {
614 return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
615 }
616 }
617
618 None
619 }
620
621 fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
622 let pool = self.model_map.get(complexity)?.get(agent)?;
623 if pool.is_empty() {
624 return Some(Vec::new());
625 }
626
627 let mut expanded_pool = Vec::new();
628 let mut has_free = false;
629
630 for model in pool {
631 let normalized = Self::normalize_model_identifier(model);
633 if normalized == "opencode:free" {
634 has_free = true;
635 } else if Self::is_valid_model_identifier(&normalized) {
636 expanded_pool.push(normalized);
637 } else {
638 tracing::debug!(agent, model, "skipping invalid model identifier in config");
639 }
640 }
641
642 if has_free {
643 expanded_pool
644 .extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
645 }
646
647 expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
649
650 if agent == "opencode" {
653 let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
654 if !discovered.is_empty() {
655 let discovered_set: std::collections::HashSet<String> =
656 discovered.into_iter().collect();
657 expanded_pool.retain(|m| discovered_set.contains(m));
658 }
659 }
660
661 Some(expanded_pool)
662 }
663
664 pub(crate) fn model_pool_for_complexity(
665 &self,
666 agent: &str,
667 complexity: &str,
668 ) -> Option<Vec<String>> {
669 self.expanded_model_pool(agent, complexity)
670 }
671
672 pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
673 match self.expanded_model_pool(agent, complexity) {
674 None => false,
675 Some(pool) => pool
676 .iter()
677 .any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
678 }
679 }
680
681 pub fn model_for_complexity(
689 &self,
690 agent: &str,
691 complexity: &str,
692 task_id: &str,
693 ) -> Option<String> {
694 let expanded_pool = self.expanded_model_pool(agent, complexity)?;
695 if expanded_pool.is_empty() {
696 return None;
697 }
698 let start =
700 crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
701 for i in 0..expanded_pool.len() {
703 let model = &expanded_pool[(start + i) % expanded_pool.len()];
704 if !Self::is_valid_model_identifier(model) {
706 tracing::debug!(
707 agent,
708 model,
709 "invalid model identifier slipped through validation"
710 );
711 continue;
712 }
713 if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
714 return Some(Self::normalize_model_identifier(model));
716 }
717 }
718 None
720 }
721
722 pub(crate) fn uses_opencode(&self) -> bool {
726 fn names_opencode(entry: &str) -> bool {
727 entry == "opencode" || entry.starts_with("opencode:")
728 }
729
730 if self
731 .model_map
732 .values()
733 .any(|by_agent| by_agent.contains_key("opencode"))
734 {
735 return true;
736 }
737
738 self.effective_pool().iter().any(|e| names_opencode(e))
739 || names_opencode(&self.effective_fallback())
740 }
741
742 fn prune_unavailable_opencode_models(
748 model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
749 discovered: &std::collections::HashSet<String>,
750 ) -> usize {
751 let mut pruned = 0;
752 for agent_models in model_map.values_mut() {
753 if let Some(models) = agent_models.get_mut("opencode") {
754 let before = models.len();
755 models.retain(|m| m == "opencode:free" || discovered.contains(m));
756 pruned += before - models.len();
757 }
758 }
759 pruned
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::{RouterConfig, MAX_ROUTER_TIMEOUT_SECS};
766 use serial_test::serial;
767 use std::sync::{Mutex, OnceLock};
768
769 struct CurrentDirGuard {
770 original: std::path::PathBuf,
771 }
772
773 impl CurrentDirGuard {
774 fn set(path: &std::path::Path) -> Self {
775 let original = std::env::current_dir().unwrap();
776 std::env::set_current_dir(path).unwrap();
777 Self { original }
778 }
779 }
780
781 impl Drop for CurrentDirGuard {
782 fn drop(&mut self) {
783 std::env::set_current_dir(&self.original).unwrap();
784 }
785 }
786
787 fn cwd_mutex() -> &'static Mutex<()> {
788 static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
789 CWD_MUTEX.get_or_init(|| Mutex::new(()))
790 }
791
792 #[serial(cooldown_state)]
793 #[tokio::test]
794 async fn model_for_complexity_returns_none_when_all_models_cooled() {
795 crate::engine::cooldown::reset_global_state().await;
796 let mut config = RouterConfig::default();
797 config
799 .model_map
800 .entry("complex".to_string())
801 .or_default()
802 .insert(
803 "testagent_allcooled".to_string(),
804 vec![
805 "cooldown-model-a".to_string(),
806 "cooldown-model-b".to_string(),
807 ],
808 );
809
810 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
812 .await;
813 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
814 .await;
815
816 let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
817 assert!(
818 result.is_none(),
819 "should return None when all models in pool are in cooldown, got {result:?}"
820 );
821 }
822
823 #[serial(cooldown_state)]
824 #[tokio::test]
825 async fn model_for_complexity_returns_none_when_single_model_cooled() {
826 crate::engine::cooldown::reset_global_state().await;
827 let mut config = RouterConfig::default();
828 config
829 .model_map
830 .entry("complex".to_string())
831 .or_default()
832 .insert(
833 "testagent_singlecooled".to_string(),
834 vec!["single-cooled-model".to_string()],
835 );
836
837 crate::engine::cooldown::record_model_failure(
838 "testagent_singlecooled",
839 "single-cooled-model",
840 )
841 .await;
842
843 let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
844 assert!(
845 result.is_none(),
846 "should return None when the only model in pool is in cooldown, got {result:?}"
847 );
848 }
849
850 #[serial(cooldown_state)]
851 #[test]
852 fn model_for_complexity_returns_model_when_not_cooled() {
853 let mut config = RouterConfig::default();
854 config
855 .model_map
856 .entry("complex".to_string())
857 .or_default()
858 .insert(
859 "testagent_notcooled".to_string(),
860 vec!["healthy-model".to_string()],
861 );
862
863 let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
865 assert_eq!(
866 result.as_deref(),
867 Some("healthy-model"),
868 "should return the model when it is not in cooldown"
869 );
870 }
871
872 #[serial(cooldown_state)]
873 #[test]
874 fn has_available_model_returns_false_when_no_pool_configured() {
875 let mut config = RouterConfig::default();
876 config
878 .model_map
879 .entry("simple".to_string())
880 .or_default()
881 .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
882 config
883 .model_map
884 .entry("medium".to_string())
885 .or_default()
886 .insert("olm".to_string(), vec!["gemma4".to_string()]);
887 assert!(
891 config.has_available_model_for_complexity("olm", "simple"),
892 "should return true when model pool exists and is configured"
893 );
894 assert!(
895 config.has_available_model_for_complexity("olm", "medium"),
896 "should return true when model pool exists and is configured"
897 );
898 assert!(
899 !config.has_available_model_for_complexity("olm", "complex"),
900 "should return false when no model pool is configured for agent+complexity"
901 );
902 assert!(
903 !config.has_available_model_for_complexity("olm", "review"),
904 "should return false when no model pool is configured for agent+complexity"
905 );
906 }
907
908 #[serial(cooldown_state)]
909 #[test]
910 fn default_self_routing_penalty_is_one() {
911 let config = RouterConfig::default();
912 assert_eq!(
913 config.self_routing_penalty, 1.0,
914 "default penalty must be 1.0 (no penalty = full self-routing allowed)"
915 );
916 }
917
918 #[serial(cooldown_state)]
919 #[test]
920 fn from_config_reads_self_routing_penalty() {
921 let _lock = cwd_mutex().lock().unwrap();
922 let dir = tempfile::tempdir().unwrap();
923 std::fs::write(
924 dir.path().join(".orch.yml"),
925 "router:\n self_routing_penalty: 0.5\n",
926 )
927 .unwrap();
928 let _guard = CurrentDirGuard::set(dir.path());
929
930 let config = RouterConfig::from_config();
931 assert_eq!(config.self_routing_penalty, 0.5);
932 }
933
934 #[serial(cooldown_state)]
935 #[test]
936 fn default_config_model_map_is_empty() {
937 let config = RouterConfig::default();
941 assert!(
942 config.model_map.is_empty(),
943 "Default RouterConfig must have no hardcoded models; got {:?}",
944 config.model_map
945 );
946 }
947
948 #[serial(cooldown_state)]
949 #[test]
950 fn from_config_reads_router_pool_yaml_array() {
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 pool:\n - opencode:free\n - opencode:github-copilot/gpt-5-mini\n - kimi:k2p5\n - claude:haiku\n",
956 )
957 .unwrap();
958 let _guard = CurrentDirGuard::set(dir.path());
959
960 let config = RouterConfig::from_config();
961
962 assert_eq!(
963 config.pool,
964 vec![
965 "opencode:free",
966 "opencode:github-copilot/gpt-5-mini",
967 "kimi:k2p5",
968 "claude:haiku",
969 ]
970 );
971 }
972
973 #[serial(cooldown_state)]
974 #[test]
975 fn from_config_clamps_router_timeout_to_max() {
976 let _lock = cwd_mutex().lock().unwrap();
977 let dir = tempfile::tempdir().unwrap();
978 std::fs::write(
979 dir.path().join(".orch.yml"),
980 "router:\n timeout_seconds: 90\n",
981 )
982 .unwrap();
983 let _guard = CurrentDirGuard::set(dir.path());
984
985 let config = RouterConfig::from_config();
986 assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
987 }
988
989 #[serial(cooldown_state)]
990 #[test]
991 fn normalize_model_identifier_strips_trailing_slash() {
992 assert_eq!(
994 RouterConfig::normalize_model_identifier("opus/"),
995 "opus",
996 "trailing slash should be stripped from model identifier"
997 );
998 assert_eq!(
999 RouterConfig::normalize_model_identifier("sonnet/"),
1000 "sonnet",
1001 "trailing slash should be stripped from model identifier"
1002 );
1003 assert_eq!(
1004 RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
1005 "github-copilot/gpt-5-mini",
1006 "trailing slash should be stripped from model identifier with internal slash"
1007 );
1008 assert_eq!(
1010 RouterConfig::normalize_model_identifier("opus//"),
1011 "opus",
1012 "multiple trailing slashes should be stripped"
1013 );
1014 assert_eq!(
1016 RouterConfig::normalize_model_identifier("opus"),
1017 "opus",
1018 "model without trailing slash should remain unchanged"
1019 );
1020 }
1021
1022 #[serial(cooldown_state)]
1023 #[test]
1024 fn is_valid_model_identifier_rejects_trailing_slash() {
1025 assert!(
1027 !RouterConfig::is_valid_model_identifier("opus/"),
1028 "model with trailing slash should be invalid"
1029 );
1030 assert!(
1031 RouterConfig::is_valid_model_identifier("opus"),
1032 "model without trailing slash should be valid"
1033 );
1034 assert!(
1035 RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1036 "model with internal slash should be valid"
1037 );
1038 assert!(
1039 !RouterConfig::is_valid_model_identifier("github-copilot/"),
1040 "model ending with slash after prefix should be invalid"
1041 );
1042 }
1043
1044 #[serial(cooldown_state)]
1045 #[test]
1046 fn from_config_loads_model_map_for_custom_agents() {
1047 let _lock = cwd_mutex().lock().unwrap();
1048 let dir = tempfile::tempdir().unwrap();
1049 std::fs::write(
1051 dir.path().join(".orch.yml"),
1052 r#"
1053agents:
1054 - claude
1055 - olm
1056model_map:
1057 simple:
1058 claude: haiku
1059 olm: qwen3.5
1060 medium:
1061 claude: sonnet
1062 olm: gemma4
1063 complex:
1064 claude: opus
1065 olm: gemma4
1066"#,
1067 )
1068 .unwrap();
1069 let _guard = CurrentDirGuard::set(dir.path());
1070
1071 let config = RouterConfig::from_config();
1072
1073 assert_eq!(
1075 config.model_for_complexity("olm", "simple", "test-task"),
1076 Some("qwen3.5".to_string()),
1077 "custom agent olm should have simple model from config"
1078 );
1079 assert_eq!(
1080 config.model_for_complexity("olm", "medium", "test-task"),
1081 Some("gemma4".to_string()),
1082 "custom agent olm should have medium model from config"
1083 );
1084 assert_eq!(
1086 config.model_for_complexity("claude", "simple", "test-task"),
1087 Some("haiku".to_string()),
1088 );
1089 }
1090
1091 #[serial(cooldown_state)]
1092 #[test]
1093 fn from_config_loads_models_shorthand_for_all_complexities() {
1094 let _lock = cwd_mutex().lock().unwrap();
1095 let dir = tempfile::tempdir().unwrap();
1096 std::fs::write(
1097 dir.path().join(".orch.yml"),
1098 r#"
1099agents:
1100 - shorthand
1101models:
1102 shorthand:
1103 - gpt-5.2
1104 - gpt-5.5
1105"#,
1106 )
1107 .unwrap();
1108 let _guard = CurrentDirGuard::set(dir.path());
1109
1110 let config = RouterConfig::from_config();
1111
1112 for complexity in ["simple", "medium", "complex", "review"] {
1113 assert_eq!(
1114 config.model_map[complexity]["shorthand"],
1115 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1116 "models.shorthand should populate {complexity}"
1117 );
1118 }
1119 }
1120
1121 #[serial(cooldown_state)]
1122 #[test]
1123 fn from_config_loads_single_model_shorthand_for_all_complexities() {
1124 let _lock = cwd_mutex().lock().unwrap();
1125 let dir = tempfile::tempdir().unwrap();
1126 std::fs::write(
1127 dir.path().join(".orch.yml"),
1128 r#"
1129agents:
1130 - shorthand
1131models:
1132 shorthand: gpt-5.2
1133"#,
1134 )
1135 .unwrap();
1136 let _guard = CurrentDirGuard::set(dir.path());
1137
1138 let config = RouterConfig::from_config();
1139
1140 for complexity in ["simple", "medium", "complex", "review"] {
1141 assert_eq!(
1142 config.model_map[complexity]["shorthand"],
1143 vec!["gpt-5.2".to_string()],
1144 "models.shorthand should populate {complexity}"
1145 );
1146 }
1147 }
1148
1149 #[serial(cooldown_state)]
1150 #[test]
1151 fn from_config_model_map_overrides_models_shorthand_per_tier() {
1152 let _lock = cwd_mutex().lock().unwrap();
1153 let dir = tempfile::tempdir().unwrap();
1154 std::fs::write(
1155 dir.path().join(".orch.yml"),
1156 r#"
1157agents:
1158 - shorthand
1159models:
1160 shorthand:
1161 - gpt-5.2
1162 - gpt-5.5
1163model_map:
1164 review:
1165 shorthand: gpt-5.6-review
1166"#,
1167 )
1168 .unwrap();
1169 let _guard = CurrentDirGuard::set(dir.path());
1170
1171 let config = RouterConfig::from_config();
1172
1173 assert_eq!(
1174 config.model_map["simple"]["shorthand"],
1175 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1176 );
1177 assert_eq!(
1178 config.model_map["medium"]["shorthand"],
1179 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1180 );
1181 assert_eq!(
1182 config.model_map["complex"]["shorthand"],
1183 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1184 );
1185 assert_eq!(
1186 config.model_map["review"]["shorthand"],
1187 vec!["gpt-5.6-review".to_string()]
1188 );
1189 }
1190
1191 #[serial(cooldown_state)]
1192 #[test]
1193 fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1194 let mut config = RouterConfig::default();
1197 config
1198 .model_map
1199 .entry("complex".to_string())
1200 .or_default()
1201 .insert(
1202 "testagent_trailing".to_string(),
1203 vec![
1204 "opus/".to_string(),
1205 "sonnet//".to_string(),
1206 "github-copilot/gpt-5-mini/".to_string(),
1207 ],
1208 );
1209
1210 let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1212 assert!(
1213 result.is_some(),
1214 "should return a model from pool with trailing slashes normalized, got None"
1215 );
1216 let model = result.unwrap();
1217 assert!(
1219 model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1220 "returned model should be normalized (no trailing slash), got: {}",
1221 model
1222 );
1223 assert!(
1225 !model.ends_with('/'),
1226 "returned model should not have trailing slash, got: {}",
1227 model
1228 );
1229 }
1230
1231 #[serial(cooldown_state)]
1234 #[test]
1235 fn prune_unavailable_opencode_models_removes_missing_models() {
1236 use std::collections::{HashMap, HashSet};
1237
1238 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1239 model_map.entry("complex".to_string()).or_default().insert(
1240 "opencode".to_string(),
1241 vec![
1242 "available-model".to_string(),
1243 "unavailable-model".to_string(),
1244 "opencode:free".to_string(), ],
1246 );
1247
1248 let mut discovered = HashSet::new();
1249 discovered.insert("available-model".to_string());
1250 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1253
1254 assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1255
1256 let remaining = model_map["complex"]["opencode"].as_slice();
1257 assert!(
1258 remaining.contains(&"available-model".to_string()),
1259 "available model should remain"
1260 );
1261 assert!(
1262 remaining.contains(&"opencode:free".to_string()),
1263 "opencode:free sentinel should never be pruned"
1264 );
1265 assert!(
1266 !remaining.contains(&"unavailable-model".to_string()),
1267 "unavailable model should be removed"
1268 );
1269 }
1270
1271 #[serial(cooldown_state)]
1272 #[test]
1273 fn prune_unavailable_opencode_models_noop_when_all_available() {
1274 use std::collections::{HashMap, HashSet};
1275
1276 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1277 model_map.entry("simple".to_string()).or_default().insert(
1278 "opencode".to_string(),
1279 vec!["model-a".to_string(), "model-b".to_string()],
1280 );
1281
1282 let discovered: HashSet<String> = ["model-a", "model-b"]
1283 .iter()
1284 .map(|s| s.to_string())
1285 .collect();
1286
1287 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1288
1289 assert_eq!(pruned, 0, "nothing to prune when all models are available");
1290 assert_eq!(
1291 model_map["simple"]["opencode"].len(),
1292 2,
1293 "both models should remain"
1294 );
1295 }
1296
1297 #[serial(cooldown_state)]
1298 #[test]
1299 fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1300 use std::collections::{HashMap, HashSet};
1301
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 use std::collections::{HashMap, HashSet};
1372
1373 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1374 for tier in ["simple", "medium", "complex", "review"] {
1375 model_map
1376 .entry(tier.to_string())
1377 .or_default()
1378 .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1379 }
1380
1381 let discovered: HashSet<String> = HashSet::new();
1383
1384 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1385
1386 assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1387 for tier in ["simple", "medium", "complex", "review"] {
1388 assert!(
1389 model_map[tier]["opencode"].is_empty(),
1390 "all entries for {} should be removed",
1391 tier
1392 );
1393 }
1394 }
1395}