use std::collections::HashMap;
pub fn min_healthy_agents_threshold() -> usize {
crate::config::get("dispatch.min_healthy_agents")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2)
}
pub fn sequential_dispatch_delay_ms() -> u64 {
crate::config::get("dispatch.sequential_delay_ms")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
}
pub fn health_check_window_hours() -> u32 {
crate::config::get("dispatch.health_check_window_hours")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::engine::cooldown::DEFAULT_HEALTH_CHECK_WINDOW_HOURS)
}
pub fn degraded_rate_limit_threshold() -> i64 {
crate::config::get("dispatch.degraded_rate_limit_threshold")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::engine::cooldown::DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD)
}
pub fn retry_base_delay_ms() -> u64 {
crate::config::get("dispatch.retry_base_delay_ms")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000)
}
pub fn retry_max_delay_ms() -> u64 {
crate::config::get("dispatch.retry_max_delay_ms")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(120_000)
}
pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
pub fn get_agent_backoff_base(agent: &str) -> i64 {
let key = format!("router.backoff_base.{agent}");
crate::config::get(&key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::engine::cooldown::BACKOFF_BASE_SECS)
}
pub fn max_tasks_per_routing_tick() -> usize {
crate::config::get("router.max_tasks_per_tick")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1)
}
pub const DEFAULT_AGENTS: &[&str] = &[
"claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
];
#[derive(Debug, Clone)]
pub struct RouterConfig {
pub mode: String,
pub router_agent: String,
pub router_model: String,
pub timeout_seconds: u64,
pub fallback_executor: String,
pub agents: Vec<String>,
pub max_route_attempts: u32,
pub allowed_tools: Vec<String>,
pub default_skills: Vec<String>,
pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
pub weighted_round_robin: bool,
pub pool: Vec<String>,
pub fallback: String,
pub self_routing_penalty: f64,
pub weights: HashMap<String, f64>,
pub skip_limited_threshold: f64,
pub agent_backoff_bases: HashMap<String, i64>,
pub max_tasks_per_tick: usize,
pub ollama_url: String,
pub ollama_model: String,
pub ollama_timeout_seconds: u64,
}
pub fn parse_pool_entry(entry: &str) -> (String, String) {
if let Some(colon_pos) = entry.find(':') {
let agent = entry[..colon_pos].to_string();
let model = entry[colon_pos + 1..].to_string();
let model = RouterConfig::normalize_model_identifier(&model);
(agent, model)
} else {
(entry.to_string(), String::new())
}
}
impl Default for RouterConfig {
fn default() -> Self {
Self {
mode: "round_robin".to_string(),
router_agent: "claude".to_string(),
router_model: "haiku".to_string(),
timeout_seconds: 45,
fallback_executor: "codex".to_string(),
agents: DEFAULT_AGENTS.iter().map(|s| s.to_string()).collect(),
max_route_attempts: 3,
pool: vec![],
fallback: String::new(),
self_routing_penalty: 1.0,
weights: HashMap::new(),
allowed_tools: vec![
"yq".to_string(),
"jq".to_string(),
"bash".to_string(),
"just".to_string(),
"git".to_string(),
"rg".to_string(),
"sed".to_string(),
"awk".to_string(),
"python3".to_string(),
"node".to_string(),
"npm".to_string(),
"bun".to_string(),
],
default_skills: vec!["gh".to_string(), "git-worktree".to_string()],
model_map: HashMap::new(),
weighted_round_robin: false,
skip_limited_threshold: 0.3,
agent_backoff_bases: HashMap::new(),
max_tasks_per_tick: 1,
ollama_url: "http://localhost:11434".to_string(),
ollama_model: "qwen2.5-coder:3b-instruct".to_string(),
ollama_timeout_seconds: 30,
}
}
}
impl RouterConfig {
pub fn from_config() -> Self {
let mut config = Self::default();
if let Ok(mode) = crate::config::get("router.mode") {
if mode == "round_robin" || mode == "llm" || mode == "local" {
config.mode = mode;
}
}
if let Ok(agent) = crate::config::get("router.agent") {
if !agent.is_empty() {
config.router_agent = agent;
}
}
if let Ok(model) = crate::config::get("router.model") {
if !model.is_empty() {
config.router_model = RouterConfig::normalize_model_identifier(&model);
}
}
if let Ok(timeout) = crate::config::get("router.timeout_seconds") {
if let Ok(secs) = timeout.parse::<u64>() {
let clamped = secs.min(MAX_ROUTER_TIMEOUT_SECS);
if clamped != secs {
static TIMEOUT_CLAMP_WARNED: std::sync::OnceLock<()> =
std::sync::OnceLock::new();
TIMEOUT_CLAMP_WARNED.get_or_init(|| {
tracing::warn!(
configured_secs = secs,
applied_secs = clamped,
max_secs = MAX_ROUTER_TIMEOUT_SECS,
"router.timeout_seconds is too high; clamping to keep routing responsive"
);
});
}
config.timeout_seconds = clamped;
}
}
if let Ok(fallback) = crate::config::get("router.fallback_executor") {
if !fallback.is_empty() {
config.fallback_executor = fallback;
}
}
config.agents = crate::engine::configured_agents();
let exclude_agents = std::env::var("ORCH_EXCLUDE_AGENTS")
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !exclude_agents.is_empty() {
let original_len = config.agents.len();
config.agents.retain(|a| !exclude_agents.contains(a));
let removed_count = original_len - config.agents.len();
if removed_count > 0 {
tracing::warn!(
"ORCH_EXCLUDE_AGENTS={}: temporarily removing {} agent(s) from router",
exclude_agents.join(","),
removed_count
);
}
}
if let Ok(max_attempts) = crate::config::get("router.max_route_attempts") {
if let Ok(n) = max_attempts.parse::<u32>() {
config.max_route_attempts = n;
}
}
if let Ok(tools_str) = crate::config::get("router.allowed_tools") {
if !tools_str.is_empty() && tools_str != "[]" {
if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
config.allowed_tools = tools_arr;
} else {
config.allowed_tools = tools_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
}
if let Ok(val) = crate::config::get("router.weighted_round_robin") {
config.weighted_round_robin = val == "true" || val == "1";
}
if let Ok(val) = crate::config::get("router.self_routing_penalty") {
if let Ok(penalty) = val.parse::<f64>() {
config.self_routing_penalty = penalty.clamp(0.0, 1.0);
}
}
for agent in &config.agents {
let key = format!("router.weights.{agent}");
if let Ok(val) = crate::config::get(&key) {
if let Ok(w) = val.parse::<f64>() {
config.weights.insert(agent.clone(), w.max(0.0));
}
}
}
for agent in &config.agents {
let key = format!("router.backoff_base.{agent}");
if let Ok(val) = crate::config::get(&key) {
if let Ok(secs) = val.parse::<i64>() {
config.agent_backoff_bases.insert(agent.clone(), secs);
}
}
}
if let Ok(val) = crate::config::get("router.max_tasks_per_tick") {
if let Ok(n) = val.parse::<usize>() {
if n > 0 {
config.max_tasks_per_tick = n;
}
}
}
match crate::config::get_list("router.pool") {
Ok(pool_list) if !pool_list.is_empty() => {
config.pool = pool_list;
}
_ => {
if let Ok(pool_raw) = crate::config::get("router.pool") {
let parsed = pool_raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if !parsed.is_empty() {
config.pool = parsed;
}
}
}
}
if let Ok(fallback) = crate::config::get("router.fallback") {
if !fallback.is_empty() {
config.fallback = fallback;
}
}
let known_agents = crate::engine::configured_agents();
let known_complexities = ["simple", "medium", "complex", "review"];
for agent in &known_agents {
let key = format!("models.{agent}");
if let Some(models) = Self::read_model_pool_from_config(&key) {
for complexity in known_complexities {
config
.model_map
.entry(complexity.to_string())
.or_default()
.insert(agent.to_string(), models.clone());
}
}
}
for complexity in known_complexities {
for agent in &known_agents {
let key = format!("model_map.{complexity}.{agent}");
if let Some(models) = Self::read_model_pool_from_config(&key) {
config
.model_map
.entry(complexity.to_string())
.or_default()
.insert(agent.to_string(), models);
}
}
}
let discovered_opencode =
crate::engine::runner::agents::opencode::discover_opencode_models();
if !discovered_opencode.is_empty() {
let discovered: std::collections::HashSet<String> =
discovered_opencode.into_iter().collect();
for complexity in known_complexities {
if let Some(agent_models) = config.model_map.get(complexity) {
if let Some(models) = agent_models.get("opencode") {
for model in models {
if model != "opencode:free" && !discovered.contains(model) {
tracing::warn!(
complexity,
model,
"opencode model from config not present in provider model list; pruning from dispatch pool"
);
}
}
}
}
}
let pruned =
Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
if pruned > 0 {
tracing::info!(
pruned_count = pruned,
"model_pruned_unavailable: removed opencode models absent from provider catalog"
);
}
}
if let Ok(skills_str) = crate::config::get("router.default_skills") {
if !skills_str.is_empty() && skills_str != "[]" {
if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
config.default_skills = skills_arr;
} else {
config.default_skills = skills_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
}
if let Ok(url) = crate::config::get("router.ollama_url") {
if !url.is_empty() {
config.ollama_url = url;
}
}
if let Ok(model) = crate::config::get("router.ollama_model") {
if !model.is_empty() {
config.ollama_model = model;
}
}
if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds") {
if let Ok(secs) = timeout.parse::<u64>() {
if secs > 0 {
config.ollama_timeout_seconds = secs;
}
}
}
config
}
pub fn effective_pool(&self) -> Vec<String> {
if !self.pool.is_empty() {
self.pool.clone()
} else {
vec![format!("{}:{}", self.router_agent, self.router_model)]
}
}
pub fn effective_fallback(&self) -> String {
if !self.fallback.is_empty() {
self.fallback.clone()
} else {
format!("{}:{}", self.router_agent, self.router_model)
}
}
fn is_valid_model_identifier(model: &str) -> bool {
if model.is_empty() {
return false;
}
if model.ends_with('/') {
return false;
}
if model.chars().any(char::is_whitespace) {
return false;
}
if let Some(slash_pos) = model.find('/') {
let before = &model[..slash_pos];
let after = &model[slash_pos + 1..];
if before.is_empty() || after.is_empty() {
return false;
}
}
true
}
pub fn normalize_model_identifier(model: &str) -> String {
model.trim_end_matches('/').to_string()
}
fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
if let Ok(list) = crate::config::get_list(key) {
if !list.is_empty() {
return Some(
list.iter()
.map(|m| RouterConfig::normalize_model_identifier(m))
.collect(),
);
}
} else if let Ok(val) = crate::config::get(key) {
if !val.is_empty() {
return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
}
}
None
}
fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
let pool = self.model_map.get(complexity)?.get(agent)?;
if pool.is_empty() {
return Some(Vec::new());
}
let mut expanded_pool = Vec::new();
let mut has_free = false;
for model in pool {
let normalized = Self::normalize_model_identifier(model);
if normalized == "opencode:free" {
has_free = true;
} else if Self::is_valid_model_identifier(&normalized) {
expanded_pool.push(normalized);
} else {
tracing::debug!(agent, model, "skipping invalid model identifier in config");
}
}
if has_free {
expanded_pool
.extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
}
expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
if agent == "opencode" {
let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
if !discovered.is_empty() {
let discovered_set: std::collections::HashSet<String> =
discovered.into_iter().collect();
expanded_pool.retain(|m| discovered_set.contains(m));
}
}
Some(expanded_pool)
}
pub(crate) fn model_pool_for_complexity(
&self,
agent: &str,
complexity: &str,
) -> Option<Vec<String>> {
self.expanded_model_pool(agent, complexity)
}
pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
match self.expanded_model_pool(agent, complexity) {
None => false,
Some(pool) => pool
.iter()
.any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
}
}
pub fn model_for_complexity(
&self,
agent: &str,
complexity: &str,
task_id: &str,
) -> Option<String> {
let expanded_pool = self.expanded_model_pool(agent, complexity)?;
if expanded_pool.is_empty() {
return None;
}
let start =
crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
for i in 0..expanded_pool.len() {
let model = &expanded_pool[(start + i) % expanded_pool.len()];
if !Self::is_valid_model_identifier(model) {
tracing::debug!(
agent,
model,
"invalid model identifier slipped through validation"
);
continue;
}
if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
return Some(Self::normalize_model_identifier(model));
}
}
None
}
fn prune_unavailable_opencode_models(
model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
discovered: &std::collections::HashSet<String>,
) -> usize {
let mut pruned = 0;
for agent_models in model_map.values_mut() {
if let Some(models) = agent_models.get_mut("opencode") {
let before = models.len();
models.retain(|m| m == "opencode:free" || discovered.contains(m));
pruned += before - models.len();
}
}
pruned
}
}
#[cfg(test)]
mod tests {
use super::{RouterConfig, MAX_ROUTER_TIMEOUT_SECS};
use serial_test::serial;
use std::sync::{Mutex, OnceLock};
struct CurrentDirGuard {
original: std::path::PathBuf,
}
impl CurrentDirGuard {
fn set(path: &std::path::Path) -> Self {
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self { original }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
std::env::set_current_dir(&self.original).unwrap();
}
}
fn cwd_mutex() -> &'static Mutex<()> {
static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
CWD_MUTEX.get_or_init(|| Mutex::new(()))
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_for_complexity_returns_none_when_all_models_cooled() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
config
.model_map
.entry("complex".to_string())
.or_default()
.insert(
"testagent_allcooled".to_string(),
vec![
"cooldown-model-a".to_string(),
"cooldown-model-b".to_string(),
],
);
crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
.await;
crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
.await;
let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
assert!(
result.is_none(),
"should return None when all models in pool are in cooldown, got {result:?}"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_for_complexity_returns_none_when_single_model_cooled() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
config
.model_map
.entry("complex".to_string())
.or_default()
.insert(
"testagent_singlecooled".to_string(),
vec!["single-cooled-model".to_string()],
);
crate::engine::cooldown::record_model_failure(
"testagent_singlecooled",
"single-cooled-model",
)
.await;
let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
assert!(
result.is_none(),
"should return None when the only model in pool is in cooldown, got {result:?}"
);
}
#[serial(cooldown_state)]
#[test]
fn model_for_complexity_returns_model_when_not_cooled() {
let mut config = RouterConfig::default();
config
.model_map
.entry("complex".to_string())
.or_default()
.insert(
"testagent_notcooled".to_string(),
vec!["healthy-model".to_string()],
);
let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
assert_eq!(
result.as_deref(),
Some("healthy-model"),
"should return the model when it is not in cooldown"
);
}
#[serial(cooldown_state)]
#[test]
fn has_available_model_returns_false_when_no_pool_configured() {
let mut config = RouterConfig::default();
config
.model_map
.entry("simple".to_string())
.or_default()
.insert("olm".to_string(), vec!["qwen3.5".to_string()]);
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("olm".to_string(), vec!["gemma4".to_string()]);
assert!(
config.has_available_model_for_complexity("olm", "simple"),
"should return true when model pool exists and is configured"
);
assert!(
config.has_available_model_for_complexity("olm", "medium"),
"should return true when model pool exists and is configured"
);
assert!(
!config.has_available_model_for_complexity("olm", "complex"),
"should return false when no model pool is configured for agent+complexity"
);
assert!(
!config.has_available_model_for_complexity("olm", "review"),
"should return false when no model pool is configured for agent+complexity"
);
}
#[serial(cooldown_state)]
#[test]
fn default_self_routing_penalty_is_one() {
let config = RouterConfig::default();
assert_eq!(
config.self_routing_penalty, 1.0,
"default penalty must be 1.0 (no penalty = full self-routing allowed)"
);
}
#[serial(cooldown_state)]
#[test]
fn from_config_reads_self_routing_penalty() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
"router:\n self_routing_penalty: 0.5\n",
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
assert_eq!(config.self_routing_penalty, 0.5);
}
#[serial(cooldown_state)]
#[test]
fn default_config_model_map_is_empty() {
let config = RouterConfig::default();
assert!(
config.model_map.is_empty(),
"Default RouterConfig must have no hardcoded models; got {:?}",
config.model_map
);
}
#[serial(cooldown_state)]
#[test]
fn from_config_reads_router_pool_yaml_array() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
"router:\n pool:\n - opencode:free\n - opencode:github-copilot/gpt-5-mini\n - kimi:k2p5\n - claude:haiku\n",
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
assert_eq!(
config.pool,
vec![
"opencode:free",
"opencode:github-copilot/gpt-5-mini",
"kimi:k2p5",
"claude:haiku",
]
);
}
#[serial(cooldown_state)]
#[test]
fn from_config_clamps_router_timeout_to_max() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
"router:\n timeout_seconds: 90\n",
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
}
#[serial(cooldown_state)]
#[test]
fn normalize_model_identifier_strips_trailing_slash() {
assert_eq!(
RouterConfig::normalize_model_identifier("opus/"),
"opus",
"trailing slash should be stripped from model identifier"
);
assert_eq!(
RouterConfig::normalize_model_identifier("sonnet/"),
"sonnet",
"trailing slash should be stripped from model identifier"
);
assert_eq!(
RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
"github-copilot/gpt-5-mini",
"trailing slash should be stripped from model identifier with internal slash"
);
assert_eq!(
RouterConfig::normalize_model_identifier("opus//"),
"opus",
"multiple trailing slashes should be stripped"
);
assert_eq!(
RouterConfig::normalize_model_identifier("opus"),
"opus",
"model without trailing slash should remain unchanged"
);
}
#[serial(cooldown_state)]
#[test]
fn is_valid_model_identifier_rejects_trailing_slash() {
assert!(
!RouterConfig::is_valid_model_identifier("opus/"),
"model with trailing slash should be invalid"
);
assert!(
RouterConfig::is_valid_model_identifier("opus"),
"model without trailing slash should be valid"
);
assert!(
RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
"model with internal slash should be valid"
);
assert!(
!RouterConfig::is_valid_model_identifier("github-copilot/"),
"model ending with slash after prefix should be invalid"
);
}
#[serial(cooldown_state)]
#[test]
fn from_config_loads_model_map_for_custom_agents() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
r#"
agents:
- claude
- olm
model_map:
simple:
claude: haiku
olm: qwen3.5
medium:
claude: sonnet
olm: gemma4
complex:
claude: opus
olm: gemma4
"#,
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
assert_eq!(
config.model_for_complexity("olm", "simple", "test-task"),
Some("qwen3.5".to_string()),
"custom agent olm should have simple model from config"
);
assert_eq!(
config.model_for_complexity("olm", "medium", "test-task"),
Some("gemma4".to_string()),
"custom agent olm should have medium model from config"
);
assert_eq!(
config.model_for_complexity("claude", "simple", "test-task"),
Some("haiku".to_string()),
);
}
#[serial(cooldown_state)]
#[test]
fn from_config_loads_models_shorthand_for_all_complexities() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
r#"
agents:
- shorthand
models:
shorthand:
- gpt-5.2
- gpt-5.5
"#,
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
for complexity in ["simple", "medium", "complex", "review"] {
assert_eq!(
config.model_map[complexity]["shorthand"],
vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
"models.shorthand should populate {complexity}"
);
}
}
#[serial(cooldown_state)]
#[test]
fn from_config_loads_single_model_shorthand_for_all_complexities() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
r#"
agents:
- shorthand
models:
shorthand: gpt-5.2
"#,
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
for complexity in ["simple", "medium", "complex", "review"] {
assert_eq!(
config.model_map[complexity]["shorthand"],
vec!["gpt-5.2".to_string()],
"models.shorthand should populate {complexity}"
);
}
}
#[serial(cooldown_state)]
#[test]
fn from_config_model_map_overrides_models_shorthand_per_tier() {
let _lock = cwd_mutex().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".orch.yml"),
r#"
agents:
- shorthand
models:
shorthand:
- gpt-5.2
- gpt-5.5
model_map:
review:
shorthand: gpt-5.6-review
"#,
)
.unwrap();
let _guard = CurrentDirGuard::set(dir.path());
let config = RouterConfig::from_config();
assert_eq!(
config.model_map["simple"]["shorthand"],
vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
);
assert_eq!(
config.model_map["medium"]["shorthand"],
vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
);
assert_eq!(
config.model_map["complex"]["shorthand"],
vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
);
assert_eq!(
config.model_map["review"]["shorthand"],
vec!["gpt-5.6-review".to_string()]
);
}
#[serial(cooldown_state)]
#[test]
fn model_for_complexity_normalizes_trailing_slash_from_pool() {
let mut config = RouterConfig::default();
config
.model_map
.entry("complex".to_string())
.or_default()
.insert(
"testagent_trailing".to_string(),
vec![
"opus/".to_string(),
"sonnet//".to_string(),
"github-copilot/gpt-5-mini/".to_string(),
],
);
let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
assert!(
result.is_some(),
"should return a model from pool with trailing slashes normalized, got None"
);
let model = result.unwrap();
assert!(
model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
"returned model should be normalized (no trailing slash), got: {}",
model
);
assert!(
!model.ends_with('/'),
"returned model should not have trailing slash, got: {}",
model
);
}
#[serial(cooldown_state)]
#[test]
fn prune_unavailable_opencode_models_removes_missing_models() {
use std::collections::{HashMap, HashSet};
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
model_map.entry("complex".to_string()).or_default().insert(
"opencode".to_string(),
vec![
"available-model".to_string(),
"unavailable-model".to_string(),
"opencode:free".to_string(), ],
);
let mut discovered = HashSet::new();
discovered.insert("available-model".to_string());
let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
let remaining = model_map["complex"]["opencode"].as_slice();
assert!(
remaining.contains(&"available-model".to_string()),
"available model should remain"
);
assert!(
remaining.contains(&"opencode:free".to_string()),
"opencode:free sentinel should never be pruned"
);
assert!(
!remaining.contains(&"unavailable-model".to_string()),
"unavailable model should be removed"
);
}
#[serial(cooldown_state)]
#[test]
fn prune_unavailable_opencode_models_noop_when_all_available() {
use std::collections::{HashMap, HashSet};
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
model_map.entry("simple".to_string()).or_default().insert(
"opencode".to_string(),
vec!["model-a".to_string(), "model-b".to_string()],
);
let discovered: HashSet<String> = ["model-a", "model-b"]
.iter()
.map(|s| s.to_string())
.collect();
let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
assert_eq!(pruned, 0, "nothing to prune when all models are available");
assert_eq!(
model_map["simple"]["opencode"].len(),
2,
"both models should remain"
);
}
#[serial(cooldown_state)]
#[test]
fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
use std::collections::{HashMap, HashSet};
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
model_map
.entry("complex".to_string())
.or_default()
.insert("claude".to_string(), vec!["some-model".to_string()]);
let discovered: HashSet<String> = HashSet::new();
let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
assert_eq!(pruned, 0, "non-opencode agents must not be touched");
assert_eq!(
model_map["complex"]["claude"].len(),
1,
"claude model should be unchanged"
);
}
#[serial(cooldown_state)]
#[test]
fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
use std::collections::{HashMap, HashSet};
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
for tier in ["simple", "medium", "complex", "review"] {
model_map
.entry(tier.to_string())
.or_default()
.insert("opencode".to_string(), vec!["gone-model".to_string()]);
}
let discovered: HashSet<String> = HashSet::new();
let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
assert_eq!(pruned, 4, "should prune one entry per complexity tier");
for tier in ["simple", "medium", "complex", "review"] {
assert!(
model_map[tier]["opencode"].is_empty(),
"all entries for {} should be removed",
tier
);
}
}
}