Skip to main content

orch/engine/router/
config.rs

1//! Router configuration — loading, defaults, and model map.
2
3use std::collections::HashMap;
4
5/// Minimum healthy agents threshold for graceful degradation.
6/// When healthy agents fall below this number, dispatch switches to sequential mode.
7pub 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
14/// Base delay in milliseconds between dispatches in sequential (degraded) mode.
15pub 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
22/// Lookback window (in hours) for the pre-emptive agent health check.
23///
24/// The health check queries `rate_limits` for events within this window.
25/// Configurable via `dispatch.health_check_window_hours` (default: 6).
26pub 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
33/// Minimum number of rate-limit events within the health check window to
34/// consider an agent degraded.
35///
36/// A single transient 429 error shouldn't mark an agent degraded; we need
37/// a pattern of repeated failures to detect genuine degradation. This
38/// threshold is compared against the count of rate_limit events in the
39/// `rate_limits` table within the health check window.
40///
41/// IMPORTANT: An agent with `rate_limit_count == 0` is NOT degraded by this
42/// criterion — it must meet or exceed the threshold. The threshold should
43/// be set to a value >= 2 to avoid false positives when there are no events.
44///
45/// Configurable via `dispatch.degraded_rate_limit_threshold` (default: 3).
46pub 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
53/// Base delay in milliseconds for exponential backoff between fallback retries.
54pub 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
61/// Maximum delay in milliseconds for exponential backoff between fallback retries.
62pub 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
69/// Upper bound for router LLM timeout (seconds).
70///
71/// Routing is a short classification step and should fail fast so a single
72/// slow model does not stall fallback to other healthy agents.
73pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
74
75/// Return the configured base backoff (seconds) for an agent.
76///
77/// Returns the value of `router.backoff_base.{agent}` if set, otherwise
78/// the default BACKOFF_BASE_SECS (5 minutes).
79///
80/// This lets opencode and other higher-failure-rate agents use a longer
81/// initial backoff to reduce wasted retry attempts against consistently
82/// failing models.
83pub 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
91/// Maximum number of tasks to route per tick. Prevents blocking the tick loop
92/// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
93/// operation involves an LLM call taking 10-45 seconds, so routing N tasks
94/// sequentially would block for N×LLM-latency seconds.
95///
96/// Configurable via `router.max_tasks_per_tick` (default: 1).
97pub 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
104/// Default agents to check in PATH.
105///
106/// All 7 agents are listed, but availability is checked at runtime via
107/// `which::which()`. Agents not installed (e.g. kimi, minimax, glm) are
108/// automatically skipped during routing. Users can customize this list
109/// in their `config.yml` under `agents:`.
110pub const DEFAULT_AGENTS: &[&str] = &[
111    "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
112];
113
114/// Router configuration.
115#[derive(Debug, Clone)]
116pub struct RouterConfig {
117    /// Routing mode: "llm", "local", or "round_robin"
118    ///
119    /// - "llm": Use cloud LLMs for routing (default, supports pool-based routing)
120    /// - "local": Use local Ollama model for routing
121    /// - "round_robin": Distribute tasks evenly across available agents
122    pub mode: String,
123    /// Which agent performs routing (default: "claude")
124    pub router_agent: String,
125    /// Model for routing (default: "haiku")
126    pub router_model: String,
127    /// Timeout for routing LLM call in seconds
128    pub timeout_seconds: u64,
129    /// Fallback executor if routing fails
130    pub fallback_executor: String,
131    /// Configurable agent list (checked against PATH at runtime)
132    pub agents: Vec<String>,
133    /// Max LLM routing attempts before falling back to round-robin
134    pub max_route_attempts: u32,
135    /// Default tools allowed
136    pub allowed_tools: Vec<String>,
137    /// Default skills to always include
138    pub default_skills: Vec<String>,
139    /// Model map for complexity levels.
140    /// Each entry is a pool of models; one is selected randomly (skipping cooled ones) per dispatch.
141    /// A single-model pool `vec!["model"]` behaves identically to the old string format.
142    pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
143    /// Enable weighted round-robin routing based on rate limit capacity.
144    /// When true, agents that hit rate limits get fewer tasks.
145    pub weighted_round_robin: bool,
146    /// Pool of `agent:model` entries to round-robin the router LLM call.
147    /// When empty, a single-entry pool is derived from `router_agent:router_model`.
148    /// Special value `opencode:free` is expanded at startup via `opencode models`.
149    pub pool: Vec<String>,
150    /// Fallback `agent:model` when all pool entries are cooled or fail.
151    /// When empty, defaults to `router_agent:router_model`.
152    pub fallback: String,
153    /// Penalty weight applied when the LLM routes a task to itself (the routing agent).
154    ///
155    /// Range: `0.0` (always redirect away from router agent) to `1.0` (no penalty, default).
156    /// When `< 1.0` and the LLM selects its own agent, the router probabilistically
157    /// redirects to another available agent, reducing self-routing bias.
158    ///
159    /// Example: `0.5` means ~50% of self-routed tasks are redirected to another agent.
160    pub self_routing_penalty: f64,
161    /// Configurable base weights per agent.
162    ///
163    /// Higher weight = more tasks routed to this agent. Agents without an explicit
164    /// weight default to 1.0. The weighted selection normalizes these into
165    /// probabilities, so `{claude: 0.6, minimax: 0.03}` means claude gets ~95%
166    /// of tasks when both are available.
167    ///
168    /// Set in `config.yml` under `router.weights`:
169    /// ```yaml
170    /// router:
171    ///   weights:
172    ///     claude: 0.6
173    ///     codex: 0.2
174    ///     opencode: 0.15
175    ///     minimax: 0.03
176    ///     kimi: 0.02
177    /// ```
178    pub weights: HashMap<String, f64>,
179    /// If an agent's routing weight falls below this threshold, consider it
180    /// degraded and skip it during proactive routing decisions. Value in
181    /// `0.0..=1.0` where `1.0` means never skip and `0.0` skips only when
182    /// weight is exactly zero (practically never). Default: `0.3`.
183    pub skip_limited_threshold: f64,
184    /// Per-agent base backoff overrides (seconds). Agents not listed use the
185    /// default BACKOFF_BASE_SECS (5 minutes). Configure as `router.backoff_base.{agent}`.
186    ///
187    /// Example: set `router.backoff_base.opencode: 600` for a 10-minute base
188    /// instead of the default 5-minute base (useful for agents with higher
189    /// failure rates like opencode at 24.8%).
190    pub agent_backoff_bases: HashMap<String, i64>,
191    /// Maximum number of tasks to route per tick. Prevents blocking the tick loop
192    /// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
193    /// operation involves an LLM call taking 10-45 seconds, so routing N tasks
194    /// sequentially would block for N×LLM-latency seconds.
195    ///
196    /// Configurable via `router.max_tasks_per_tick` (default: 1).
197    pub max_tasks_per_tick: usize,
198    /// Ollama base URL for local routing (default: "http://localhost:11434").
199    pub ollama_url: String,
200    /// Ollama model name for routing (default: "qwen2.5-coder:3b-instruct").
201    pub ollama_model: String,
202    /// Timeout for Ollama HTTP call in seconds (default: 30).
203    pub ollama_timeout_seconds: u64,
204}
205
206/// Parse an `agent:model` pool entry string, splitting on the first colon.
207///
208/// Examples:
209/// - `"claude:haiku"` → `("claude", "haiku")`
210/// - `"opencode:github-copilot/gpt-5-mini"` → `("opencode", "github-copilot/gpt-5-mini")`
211/// - `"claude"` → `("claude", "")`
212pub 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        // Normalize trailing slashes in model part (defense-in-depth for bug #1507)
217        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 is intentionally empty — config.yml is the sole source of truth.
254            // Hardcoded models cause "Model not found" failures when the model does not
255            // exist for a particular agent (e.g. anthropic/ prefixed models for opencode).
256            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    /// Load configuration from config files.
270    pub fn from_config() -> Self {
271        let mut config = Self::default();
272
273        // Try to load from config
274        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        // Parse agents list from top-level `agents:` config key
318        config.agents = crate::engine::configured_agents();
319
320        // Temporary operational toggle: allow disabling agents without editing
321        // config files. Controlled by ORCH_EXCLUDE_AGENTS env var (comma-separated
322        // list of agent names to exclude from routing). This keeps changes reversible
323        // (unset the env var to restore behavior) and avoids editing user config files.
324        // Example: ORCH_EXCLUDE_AGENTS=glm,some-other-agent
325        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        // Parse allowed_tools as comma-separated or YAML array
353        if let Ok(tools_str) = crate::config::get("router.allowed_tools") {
354            if !tools_str.is_empty() && tools_str != "[]" {
355                // Try to parse as JSON/YAML array first
356                if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
357                    config.allowed_tools = tools_arr;
358                } else {
359                    // Fall back to comma-separated
360                    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        // Parse weighted_round_robin
370        if let Ok(val) = crate::config::get("router.weighted_round_robin") {
371            config.weighted_round_robin = val == "true" || val == "1";
372        }
373
374        // Parse self_routing_penalty
375        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        // Parse per-agent weights from router.weights.<agent>
382        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        // Parse per-agent backoff base seconds from router.backoff_base.<agent>
392        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        // Parse max_tasks_per_tick
402        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        // Parse pool: list of "agent:model" entries.
411        // `config::get_list()` returns Ok(vec![]) when the key is missing, so we
412        // can't rely on `is_ok()` to indicate presence.
413        match crate::config::get_list("router.pool") {
414            Ok(pool_list) if !pool_list.is_empty() => {
415                config.pool = pool_list;
416            }
417            _ => {
418                // Accept comma-separated string lists: "a,b,c"
419                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        // Parse fallback: single "agent:model" entry
433        if let Ok(fallback) = crate::config::get("router.fallback") {
434            if !fallback.is_empty() {
435                config.fallback = fallback;
436            }
437        }
438
439        // Load top-level model shorthand from config (models.{agent}).
440        //
441        // These candidates apply to every complexity tier by default. More
442        // specific model_map.{complexity}.{agent} entries below override the
443        // shorthand for that one tier.
444        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        // Load model_map overrides from config (model_map.{complexity}.{agent})
460        // Value may be a single string ("model-name") or a JSON array (["m1","m2"]).
461        // Iterate over all known complexity tiers regardless of what is in model_map —
462        // the Default impl has no hardcoded entries, so iterating over keys() would be a no-op.
463        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        // Startup config hygiene: warn about and prune opencode model_map entries that
477        // the provider catalog does not currently list.  Running this at load time avoids
478        // dispatching unavailable models during routing — the dynamic filter in
479        // `expanded_model_pool` also catches these, but fails open when discovery returns
480        // empty (e.g. during a background cache refresh), allowing stale models through.
481        let discovered_opencode =
482            crate::engine::runner::agents::opencode::discover_opencode_models();
483        if !discovered_opencode.is_empty() {
484            let discovered: std::collections::HashSet<String> =
485                discovered_opencode.into_iter().collect();
486            for complexity in known_complexities {
487                if let Some(agent_models) = config.model_map.get(complexity) {
488                    if let Some(models) = agent_models.get("opencode") {
489                        for model in models {
490                            if model != "opencode:free" && !discovered.contains(model) {
491                                tracing::warn!(
492                                    complexity,
493                                    model,
494                                    "opencode model from config not present in provider model list; pruning from dispatch pool"
495                                );
496                            }
497                        }
498                    }
499                }
500            }
501            let pruned =
502                Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
503            if pruned > 0 {
504                tracing::info!(
505                    pruned_count = pruned,
506                    "model_pruned_unavailable: removed opencode models absent from provider catalog"
507                );
508            }
509        }
510
511        // Parse default_skills
512        if let Ok(skills_str) = crate::config::get("router.default_skills") {
513            if !skills_str.is_empty() && skills_str != "[]" {
514                if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
515                    config.default_skills = skills_arr;
516                } else {
517                    config.default_skills = skills_str
518                        .split(',')
519                        .map(|s| s.trim().to_string())
520                        .filter(|s| !s.is_empty())
521                        .collect();
522                }
523            }
524        }
525
526        // Parse Ollama-specific config for local routing mode
527        if let Ok(url) = crate::config::get("router.ollama_url") {
528            if !url.is_empty() {
529                config.ollama_url = url;
530            }
531        }
532        if let Ok(model) = crate::config::get("router.ollama_model") {
533            if !model.is_empty() {
534                config.ollama_model = model;
535            }
536        }
537        if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds") {
538            if let Ok(secs) = timeout.parse::<u64>() {
539                if secs > 0 {
540                    config.ollama_timeout_seconds = secs;
541                }
542            }
543        }
544
545        config
546    }
547
548    /// Return the effective pool — the configured pool, or a single-entry pool from `router_agent:router_model`.
549    pub fn effective_pool(&self) -> Vec<String> {
550        if !self.pool.is_empty() {
551            self.pool.clone()
552        } else {
553            vec![format!("{}:{}", self.router_agent, self.router_model)]
554        }
555    }
556
557    /// Return the effective fallback entry — the configured fallback, or `router_agent:router_model`.
558    pub fn effective_fallback(&self) -> String {
559        if !self.fallback.is_empty() {
560            self.fallback.clone()
561        } else {
562            format!("{}:{}", self.router_agent, self.router_model)
563        }
564    }
565
566    /// Check if a model identifier looks syntactically valid.
567    /// Rejects empty strings, strings ending with slash, and strings with whitespace.
568    fn is_valid_model_identifier(model: &str) -> bool {
569        if model.is_empty() {
570            return false;
571        }
572        // Reject trailing slash (e.g., "opus/")
573        if model.ends_with('/') {
574            return false;
575        }
576        // Reject whitespace
577        if model.chars().any(char::is_whitespace) {
578            return false;
579        }
580        // Ensure that if there is a slash, both parts are non-empty
581        if let Some(slash_pos) = model.find('/') {
582            let before = &model[..slash_pos];
583            let after = &model[slash_pos + 1..];
584            if before.is_empty() || after.is_empty() {
585                return false;
586            }
587        }
588        true
589    }
590
591    /// Normalize a model identifier by stripping trailing slashes.
592    /// This is a defense-in-depth measure to handle edge cases where
593    /// a model string might have been incorrectly formatted.
594    pub fn normalize_model_identifier(model: &str) -> String {
595        model.trim_end_matches('/').to_string()
596    }
597
598    fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
599        if let Ok(list) = crate::config::get_list(key) {
600            if !list.is_empty() {
601                return Some(
602                    list.iter()
603                        .map(|m| RouterConfig::normalize_model_identifier(m))
604                        .collect(),
605                );
606            }
607        } else if let Ok(val) = crate::config::get(key) {
608            if !val.is_empty() {
609                return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
610            }
611        }
612
613        None
614    }
615
616    fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
617        let pool = self.model_map.get(complexity)?.get(agent)?;
618        if pool.is_empty() {
619            return Some(Vec::new());
620        }
621
622        let mut expanded_pool = Vec::new();
623        let mut has_free = false;
624
625        for model in pool {
626            // Normalize the model identifier first to handle edge cases like trailing slashes
627            let normalized = Self::normalize_model_identifier(model);
628            if normalized == "opencode:free" {
629                has_free = true;
630            } else if Self::is_valid_model_identifier(&normalized) {
631                expanded_pool.push(normalized);
632            } else {
633                tracing::debug!(agent, model, "skipping invalid model identifier in config");
634            }
635        }
636
637        if has_free {
638            expanded_pool
639                .extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
640        }
641
642        // Also filter out invalid discovered free models (should be valid but just in case)
643        expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
644
645        // For opencode, filter configured entries against the provider model catalog
646        // when available. This proactively avoids dispatching non-existent model IDs.
647        if agent == "opencode" {
648            let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
649            if !discovered.is_empty() {
650                let discovered_set: std::collections::HashSet<String> =
651                    discovered.into_iter().collect();
652                expanded_pool.retain(|m| discovered_set.contains(m));
653            }
654        }
655
656        Some(expanded_pool)
657    }
658
659    pub(crate) fn model_pool_for_complexity(
660        &self,
661        agent: &str,
662        complexity: &str,
663    ) -> Option<Vec<String>> {
664        self.expanded_model_pool(agent, complexity)
665    }
666
667    pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
668        match self.expanded_model_pool(agent, complexity) {
669            None => false,
670            Some(pool) => pool
671                .iter()
672                .any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
673        }
674    }
675
676    /// Get the model for a given agent and complexity level.
677    ///
678    /// When the complexity tier has a pool of models, selects randomly using
679    /// `task_id` as an entropy source and skips models currently in cooldown.
680    /// Falls back to `pool[0]` if all models are cooled.
681    ///
682    /// Backward-compatible: a single-model pool always returns that model.
683    pub fn model_for_complexity(
684        &self,
685        agent: &str,
686        complexity: &str,
687        task_id: &str,
688    ) -> Option<String> {
689        let expanded_pool = self.expanded_model_pool(agent, complexity)?;
690        if expanded_pool.is_empty() {
691            return None;
692        }
693        // Random starting index — varies per task_id to distribute across the pool
694        let start =
695            crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
696        // Walk the pool from start, skipping cooled and invalid models
697        for i in 0..expanded_pool.len() {
698            let model = &expanded_pool[(start + i) % expanded_pool.len()];
699            // Safety check: skip invalid identifiers (should already be filtered)
700            if !Self::is_valid_model_identifier(model) {
701                tracing::debug!(
702                    agent,
703                    model,
704                    "invalid model identifier slipped through validation"
705                );
706                continue;
707            }
708            if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
709                // Normalize the model identifier to handle edge cases like trailing slashes
710                return Some(Self::normalize_model_identifier(model));
711            }
712        }
713        // All models cooled — return None so the caller can fall back to a different agent
714        None
715    }
716
717    /// Remove `model_map` entries for the `opencode` agent whose identifiers do not
718    /// appear in `discovered`.  The sentinel value `"opencode:free"` is never pruned
719    /// because it is expanded at routing time and is not dispatched as a literal model ID.
720    ///
721    /// Returns the total number of model entries removed across all complexity tiers.
722    fn prune_unavailable_opencode_models(
723        model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
724        discovered: &std::collections::HashSet<String>,
725    ) -> usize {
726        let mut pruned = 0;
727        for agent_models in model_map.values_mut() {
728            if let Some(models) = agent_models.get_mut("opencode") {
729                let before = models.len();
730                models.retain(|m| m == "opencode:free" || discovered.contains(m));
731                pruned += before - models.len();
732            }
733        }
734        pruned
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::{RouterConfig, MAX_ROUTER_TIMEOUT_SECS};
741    use serial_test::serial;
742    use std::sync::{Mutex, OnceLock};
743
744    struct CurrentDirGuard {
745        original: std::path::PathBuf,
746    }
747
748    impl CurrentDirGuard {
749        fn set(path: &std::path::Path) -> Self {
750            let original = std::env::current_dir().unwrap();
751            std::env::set_current_dir(path).unwrap();
752            Self { original }
753        }
754    }
755
756    impl Drop for CurrentDirGuard {
757        fn drop(&mut self) {
758            std::env::set_current_dir(&self.original).unwrap();
759        }
760    }
761
762    fn cwd_mutex() -> &'static Mutex<()> {
763        static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
764        CWD_MUTEX.get_or_init(|| Mutex::new(()))
765    }
766
767    #[serial(cooldown_state)]
768    #[tokio::test]
769    async fn model_for_complexity_returns_none_when_all_models_cooled() {
770        crate::engine::cooldown::reset_global_state().await;
771        let mut config = RouterConfig::default();
772        // Inject a two-model pool for a unique agent name to avoid cross-test pollution
773        config
774            .model_map
775            .entry("complex".to_string())
776            .or_default()
777            .insert(
778                "testagent_allcooled".to_string(),
779                vec![
780                    "cooldown-model-a".to_string(),
781                    "cooldown-model-b".to_string(),
782                ],
783            );
784
785        // Put both models into cooldown
786        crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
787            .await;
788        crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
789            .await;
790
791        let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
792        assert!(
793            result.is_none(),
794            "should return None when all models in pool are in cooldown, got {result:?}"
795        );
796    }
797
798    #[serial(cooldown_state)]
799    #[tokio::test]
800    async fn model_for_complexity_returns_none_when_single_model_cooled() {
801        crate::engine::cooldown::reset_global_state().await;
802        let mut config = RouterConfig::default();
803        config
804            .model_map
805            .entry("complex".to_string())
806            .or_default()
807            .insert(
808                "testagent_singlecooled".to_string(),
809                vec!["single-cooled-model".to_string()],
810            );
811
812        crate::engine::cooldown::record_model_failure(
813            "testagent_singlecooled",
814            "single-cooled-model",
815        )
816        .await;
817
818        let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
819        assert!(
820            result.is_none(),
821            "should return None when the only model in pool is in cooldown, got {result:?}"
822        );
823    }
824
825    #[serial(cooldown_state)]
826    #[test]
827    fn model_for_complexity_returns_model_when_not_cooled() {
828        let mut config = RouterConfig::default();
829        config
830            .model_map
831            .entry("complex".to_string())
832            .or_default()
833            .insert(
834                "testagent_notcooled".to_string(),
835                vec!["healthy-model".to_string()],
836            );
837
838        // Ensure no cooldown is active for this specific model
839        let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
840        assert_eq!(
841            result.as_deref(),
842            Some("healthy-model"),
843            "should return the model when it is not in cooldown"
844        );
845    }
846
847    #[serial(cooldown_state)]
848    #[test]
849    fn has_available_model_returns_false_when_no_pool_configured() {
850        let mut config = RouterConfig::default();
851        // Simulate olm agent with only simple and medium configured (missing complex and review)
852        config
853            .model_map
854            .entry("simple".to_string())
855            .or_default()
856            .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
857        config
858            .model_map
859            .entry("medium".to_string())
860            .or_default()
861            .insert("olm".to_string(), vec!["gemma4".to_string()]);
862        // Note: complex and review tiers are NOT configured for olm
863
864        // has_available_model_for_complexity should return false when no pool exists
865        assert!(
866            config.has_available_model_for_complexity("olm", "simple"),
867            "should return true when model pool exists and is configured"
868        );
869        assert!(
870            config.has_available_model_for_complexity("olm", "medium"),
871            "should return true when model pool exists and is configured"
872        );
873        assert!(
874            !config.has_available_model_for_complexity("olm", "complex"),
875            "should return false when no model pool is configured for agent+complexity"
876        );
877        assert!(
878            !config.has_available_model_for_complexity("olm", "review"),
879            "should return false when no model pool is configured for agent+complexity"
880        );
881    }
882
883    #[serial(cooldown_state)]
884    #[test]
885    fn default_self_routing_penalty_is_one() {
886        let config = RouterConfig::default();
887        assert_eq!(
888            config.self_routing_penalty, 1.0,
889            "default penalty must be 1.0 (no penalty = full self-routing allowed)"
890        );
891    }
892
893    #[serial(cooldown_state)]
894    #[test]
895    fn from_config_reads_self_routing_penalty() {
896        let _lock = cwd_mutex().lock().unwrap();
897        let dir = tempfile::tempdir().unwrap();
898        std::fs::write(
899            dir.path().join(".orch.yml"),
900            "router:\n  self_routing_penalty: 0.5\n",
901        )
902        .unwrap();
903        let _guard = CurrentDirGuard::set(dir.path());
904
905        let config = RouterConfig::from_config();
906        assert_eq!(config.self_routing_penalty, 0.5);
907    }
908
909    #[serial(cooldown_state)]
910    #[test]
911    fn default_config_model_map_is_empty() {
912        // model_map must be empty — config.yml is the sole source of truth for models.
913        // Hardcoded defaults (especially anthropic/ prefixed opencode models) cause
914        // "Model not found" failures when the model does not exist for a given agent.
915        let config = RouterConfig::default();
916        assert!(
917            config.model_map.is_empty(),
918            "Default RouterConfig must have no hardcoded models; got {:?}",
919            config.model_map
920        );
921    }
922
923    #[serial(cooldown_state)]
924    #[test]
925    fn from_config_reads_router_pool_yaml_array() {
926        let _lock = cwd_mutex().lock().unwrap();
927        let dir = tempfile::tempdir().unwrap();
928        std::fs::write(
929            dir.path().join(".orch.yml"),
930            "router:\n  pool:\n    - opencode:free\n    - opencode:github-copilot/gpt-5-mini\n    - kimi:k2p5\n    - claude:haiku\n",
931        )
932        .unwrap();
933        let _guard = CurrentDirGuard::set(dir.path());
934
935        let config = RouterConfig::from_config();
936
937        assert_eq!(
938            config.pool,
939            vec![
940                "opencode:free",
941                "opencode:github-copilot/gpt-5-mini",
942                "kimi:k2p5",
943                "claude:haiku",
944            ]
945        );
946    }
947
948    #[serial(cooldown_state)]
949    #[test]
950    fn from_config_clamps_router_timeout_to_max() {
951        let _lock = cwd_mutex().lock().unwrap();
952        let dir = tempfile::tempdir().unwrap();
953        std::fs::write(
954            dir.path().join(".orch.yml"),
955            "router:\n  timeout_seconds: 90\n",
956        )
957        .unwrap();
958        let _guard = CurrentDirGuard::set(dir.path());
959
960        let config = RouterConfig::from_config();
961        assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
962    }
963
964    #[serial(cooldown_state)]
965    #[test]
966    fn normalize_model_identifier_strips_trailing_slash() {
967        // Bug #1507: model assignment was producing "opus/" instead of "opus"
968        assert_eq!(
969            RouterConfig::normalize_model_identifier("opus/"),
970            "opus",
971            "trailing slash should be stripped from model identifier"
972        );
973        assert_eq!(
974            RouterConfig::normalize_model_identifier("sonnet/"),
975            "sonnet",
976            "trailing slash should be stripped from model identifier"
977        );
978        assert_eq!(
979            RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
980            "github-copilot/gpt-5-mini",
981            "trailing slash should be stripped from model identifier with internal slash"
982        );
983        // Edge cases: multiple trailing slashes
984        assert_eq!(
985            RouterConfig::normalize_model_identifier("opus//"),
986            "opus",
987            "multiple trailing slashes should be stripped"
988        );
989        // Edge cases: no trailing slash
990        assert_eq!(
991            RouterConfig::normalize_model_identifier("opus"),
992            "opus",
993            "model without trailing slash should remain unchanged"
994        );
995    }
996
997    #[serial(cooldown_state)]
998    #[test]
999    fn is_valid_model_identifier_rejects_trailing_slash() {
1000        // Ensure validation correctly rejects models with trailing slashes
1001        assert!(
1002            !RouterConfig::is_valid_model_identifier("opus/"),
1003            "model with trailing slash should be invalid"
1004        );
1005        assert!(
1006            RouterConfig::is_valid_model_identifier("opus"),
1007            "model without trailing slash should be valid"
1008        );
1009        assert!(
1010            RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1011            "model with internal slash should be valid"
1012        );
1013        assert!(
1014            !RouterConfig::is_valid_model_identifier("github-copilot/"),
1015            "model ending with slash after prefix should be invalid"
1016        );
1017    }
1018
1019    #[serial(cooldown_state)]
1020    #[test]
1021    fn from_config_loads_model_map_for_custom_agents() {
1022        let _lock = cwd_mutex().lock().unwrap();
1023        let dir = tempfile::tempdir().unwrap();
1024        // Simulate a config with a custom agent (olm) in both agents and model_map
1025        std::fs::write(
1026            dir.path().join(".orch.yml"),
1027            r#"
1028agents:
1029  - claude
1030  - olm
1031model_map:
1032  simple:
1033    claude: haiku
1034    olm: qwen3.5
1035  medium:
1036    claude: sonnet
1037    olm: gemma4
1038  complex:
1039    claude: opus
1040    olm: gemma4
1041"#,
1042        )
1043        .unwrap();
1044        let _guard = CurrentDirGuard::set(dir.path());
1045
1046        let config = RouterConfig::from_config();
1047
1048        // olm models should be loaded from the model_map
1049        assert_eq!(
1050            config.model_for_complexity("olm", "simple", "test-task"),
1051            Some("qwen3.5".to_string()),
1052            "custom agent olm should have simple model from config"
1053        );
1054        assert_eq!(
1055            config.model_for_complexity("olm", "medium", "test-task"),
1056            Some("gemma4".to_string()),
1057            "custom agent olm should have medium model from config"
1058        );
1059        // claude should also work
1060        assert_eq!(
1061            config.model_for_complexity("claude", "simple", "test-task"),
1062            Some("haiku".to_string()),
1063        );
1064    }
1065
1066    #[serial(cooldown_state)]
1067    #[test]
1068    fn from_config_loads_models_shorthand_for_all_complexities() {
1069        let _lock = cwd_mutex().lock().unwrap();
1070        let dir = tempfile::tempdir().unwrap();
1071        std::fs::write(
1072            dir.path().join(".orch.yml"),
1073            r#"
1074agents:
1075  - shorthand
1076models:
1077  shorthand:
1078    - gpt-5.2
1079    - gpt-5.5
1080"#,
1081        )
1082        .unwrap();
1083        let _guard = CurrentDirGuard::set(dir.path());
1084
1085        let config = RouterConfig::from_config();
1086
1087        for complexity in ["simple", "medium", "complex", "review"] {
1088            assert_eq!(
1089                config.model_map[complexity]["shorthand"],
1090                vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1091                "models.shorthand should populate {complexity}"
1092            );
1093        }
1094    }
1095
1096    #[serial(cooldown_state)]
1097    #[test]
1098    fn from_config_loads_single_model_shorthand_for_all_complexities() {
1099        let _lock = cwd_mutex().lock().unwrap();
1100        let dir = tempfile::tempdir().unwrap();
1101        std::fs::write(
1102            dir.path().join(".orch.yml"),
1103            r#"
1104agents:
1105  - shorthand
1106models:
1107  shorthand: gpt-5.2
1108"#,
1109        )
1110        .unwrap();
1111        let _guard = CurrentDirGuard::set(dir.path());
1112
1113        let config = RouterConfig::from_config();
1114
1115        for complexity in ["simple", "medium", "complex", "review"] {
1116            assert_eq!(
1117                config.model_map[complexity]["shorthand"],
1118                vec!["gpt-5.2".to_string()],
1119                "models.shorthand should populate {complexity}"
1120            );
1121        }
1122    }
1123
1124    #[serial(cooldown_state)]
1125    #[test]
1126    fn from_config_model_map_overrides_models_shorthand_per_tier() {
1127        let _lock = cwd_mutex().lock().unwrap();
1128        let dir = tempfile::tempdir().unwrap();
1129        std::fs::write(
1130            dir.path().join(".orch.yml"),
1131            r#"
1132agents:
1133  - shorthand
1134models:
1135  shorthand:
1136    - gpt-5.2
1137    - gpt-5.5
1138model_map:
1139  review:
1140    shorthand: gpt-5.6-review
1141"#,
1142        )
1143        .unwrap();
1144        let _guard = CurrentDirGuard::set(dir.path());
1145
1146        let config = RouterConfig::from_config();
1147
1148        assert_eq!(
1149            config.model_map["simple"]["shorthand"],
1150            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1151        );
1152        assert_eq!(
1153            config.model_map["medium"]["shorthand"],
1154            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1155        );
1156        assert_eq!(
1157            config.model_map["complex"]["shorthand"],
1158            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1159        );
1160        assert_eq!(
1161            config.model_map["review"]["shorthand"],
1162            vec!["gpt-5.6-review".to_string()]
1163        );
1164    }
1165
1166    #[serial(cooldown_state)]
1167    #[test]
1168    fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1169        // Bug #1507: full path test - model identifiers with trailing slashes
1170        // should be normalized (not dropped) when loading from model_map pool
1171        let mut config = RouterConfig::default();
1172        config
1173            .model_map
1174            .entry("complex".to_string())
1175            .or_default()
1176            .insert(
1177                "testagent_trailing".to_string(),
1178                vec![
1179                    "opus/".to_string(),
1180                    "sonnet//".to_string(),
1181                    "github-copilot/gpt-5-mini/".to_string(),
1182                ],
1183            );
1184
1185        // Should return normalized "opus" instead of dropping it as invalid
1186        let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1187        assert!(
1188            result.is_some(),
1189            "should return a model from pool with trailing slashes normalized, got None"
1190        );
1191        let model = result.unwrap();
1192        // The model returned should be one of the normalized versions
1193        assert!(
1194            model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1195            "returned model should be normalized (no trailing slash), got: {}",
1196            model
1197        );
1198        // Verify no trailing slashes in the returned model
1199        assert!(
1200            !model.ends_with('/'),
1201            "returned model should not have trailing slash, got: {}",
1202            model
1203        );
1204    }
1205
1206    // --- prune_unavailable_opencode_models regression tests (bug #3169) ---
1207
1208    #[serial(cooldown_state)]
1209    #[test]
1210    fn prune_unavailable_opencode_models_removes_missing_models() {
1211        use std::collections::{HashMap, HashSet};
1212
1213        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1214        model_map.entry("complex".to_string()).or_default().insert(
1215            "opencode".to_string(),
1216            vec![
1217                "available-model".to_string(),
1218                "unavailable-model".to_string(),
1219                "opencode:free".to_string(), // sentinel — must survive pruning
1220            ],
1221        );
1222
1223        let mut discovered = HashSet::new();
1224        discovered.insert("available-model".to_string());
1225        // "unavailable-model" intentionally absent
1226
1227        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1228
1229        assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1230
1231        let remaining = model_map["complex"]["opencode"].as_slice();
1232        assert!(
1233            remaining.contains(&"available-model".to_string()),
1234            "available model should remain"
1235        );
1236        assert!(
1237            remaining.contains(&"opencode:free".to_string()),
1238            "opencode:free sentinel should never be pruned"
1239        );
1240        assert!(
1241            !remaining.contains(&"unavailable-model".to_string()),
1242            "unavailable model should be removed"
1243        );
1244    }
1245
1246    #[serial(cooldown_state)]
1247    #[test]
1248    fn prune_unavailable_opencode_models_noop_when_all_available() {
1249        use std::collections::{HashMap, HashSet};
1250
1251        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1252        model_map.entry("simple".to_string()).or_default().insert(
1253            "opencode".to_string(),
1254            vec!["model-a".to_string(), "model-b".to_string()],
1255        );
1256
1257        let discovered: HashSet<String> = ["model-a", "model-b"]
1258            .iter()
1259            .map(|s| s.to_string())
1260            .collect();
1261
1262        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1263
1264        assert_eq!(pruned, 0, "nothing to prune when all models are available");
1265        assert_eq!(
1266            model_map["simple"]["opencode"].len(),
1267            2,
1268            "both models should remain"
1269        );
1270    }
1271
1272    #[serial(cooldown_state)]
1273    #[test]
1274    fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1275        use std::collections::{HashMap, HashSet};
1276
1277        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1278        model_map
1279            .entry("complex".to_string())
1280            .or_default()
1281            .insert("claude".to_string(), vec!["some-model".to_string()]);
1282
1283        // discovered is empty — irrelevant for non-opencode agents
1284        let discovered: HashSet<String> = HashSet::new();
1285
1286        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1287
1288        assert_eq!(pruned, 0, "non-opencode agents must not be touched");
1289        assert_eq!(
1290            model_map["complex"]["claude"].len(),
1291            1,
1292            "claude model should be unchanged"
1293        );
1294    }
1295
1296    #[serial(cooldown_state)]
1297    #[test]
1298    fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
1299        use std::collections::{HashMap, HashSet};
1300
1301        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1302        for tier in ["simple", "medium", "complex", "review"] {
1303            model_map
1304                .entry(tier.to_string())
1305                .or_default()
1306                .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1307        }
1308
1309        // "gone-model" is not in discovered
1310        let discovered: HashSet<String> = HashSet::new();
1311
1312        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1313
1314        assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1315        for tier in ["simple", "medium", "complex", "review"] {
1316            assert!(
1317                model_map[tier]["opencode"].is_empty(),
1318                "all entries for {} should be removed",
1319                tier
1320            );
1321        }
1322    }
1323}