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        // Reading the catalog costs an `opencode models` run, so only configs that can
482        // reach opencode pay for it
483        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        // Parse default_skills
517        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        // Parse Ollama-specific config for local routing mode
532        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    /// Return the effective pool — the configured pool, or a single-entry pool from `router_agent:router_model`.
554    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    /// Return the effective fallback entry — the configured fallback, or `router_agent:router_model`.
563    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    /// Check if a model identifier looks syntactically valid.
572    /// Rejects empty strings, strings ending with slash, and strings with whitespace.
573    fn is_valid_model_identifier(model: &str) -> bool {
574        if model.is_empty() {
575            return false;
576        }
577        // Reject trailing slash (e.g., "opus/")
578        if model.ends_with('/') {
579            return false;
580        }
581        // Reject whitespace
582        if model.chars().any(char::is_whitespace) {
583            return false;
584        }
585        // Ensure that if there is a slash, both parts are non-empty
586        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    /// Normalize a model identifier by stripping trailing slashes.
597    /// This is a defense-in-depth measure to handle edge cases where
598    /// a model string might have been incorrectly formatted.
599    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            // Normalize the model identifier first to handle edge cases like trailing slashes
632            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        // Also filter out invalid discovered free models (should be valid but just in case)
648        expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
649
650        // For opencode, filter configured entries against the provider model catalog
651        // when available. This proactively avoids dispatching non-existent model IDs.
652        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    /// Get the model for a given agent and complexity level.
682    ///
683    /// When the complexity tier has a pool of models, selects randomly using
684    /// `task_id` as an entropy source and skips models currently in cooldown.
685    /// Falls back to `pool[0]` if all models are cooled.
686    ///
687    /// Backward-compatible: a single-model pool always returns that model.
688    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        // Random starting index — varies per task_id to distribute across the pool
699        let start =
700            crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
701        // Walk the pool from start, skipping cooled and invalid models
702        for i in 0..expanded_pool.len() {
703            let model = &expanded_pool[(start + i) % expanded_pool.len()];
704            // Safety check: skip invalid identifiers (should already be filtered)
705            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                // Normalize the model identifier to handle edge cases like trailing slashes
715                return Some(Self::normalize_model_identifier(model));
716            }
717        }
718        // All models cooled — return None so the caller can fall back to a different agent
719        None
720    }
721
722    /// True when this config can reach opencode: a `model_map` tier lists it, or the
723    /// effective router pool or fallback names it. Those are the only entries read out of
724    /// `opencode models`, so every other config can skip the call
725    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    /// Remove `model_map` entries for the `opencode` agent whose identifiers do not
743    /// appear in `discovered`.  The sentinel value `"opencode:free"` is never pruned
744    /// because it is expanded at routing time and is not dispatched as a literal model ID.
745    ///
746    /// Returns the total number of model entries removed across all complexity tiers.
747    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        // Inject a two-model pool for a unique agent name to avoid cross-test pollution
798        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        // Put both models into cooldown
811        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        // Ensure no cooldown is active for this specific model
864        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        // Simulate olm agent with only simple and medium configured (missing complex and review)
877        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        // Note: complex and review tiers are NOT configured for olm
888
889        // has_available_model_for_complexity should return false when no pool exists
890        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        // model_map must be empty — config.yml is the sole source of truth for models.
938        // Hardcoded defaults (especially anthropic/ prefixed opencode models) cause
939        // "Model not found" failures when the model does not exist for a given agent.
940        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        // Bug #1507: model assignment was producing "opus/" instead of "opus"
993        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        // Edge cases: multiple trailing slashes
1009        assert_eq!(
1010            RouterConfig::normalize_model_identifier("opus//"),
1011            "opus",
1012            "multiple trailing slashes should be stripped"
1013        );
1014        // Edge cases: no trailing slash
1015        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        // Ensure validation correctly rejects models with trailing slashes
1026        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        // Simulate a config with a custom agent (olm) in both agents and model_map
1050        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        // olm models should be loaded from the model_map
1074        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        // claude should also work
1085        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        // Bug #1507: full path test - model identifiers with trailing slashes
1195        // should be normalized (not dropped) when loading from model_map pool
1196        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        // Should return normalized "opus" instead of dropping it as invalid
1211        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        // The model returned should be one of the normalized versions
1218        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        // Verify no trailing slashes in the returned model
1224        assert!(
1225            !model.ends_with('/'),
1226            "returned model should not have trailing slash, got: {}",
1227            model
1228        );
1229    }
1230
1231    // --- prune_unavailable_opencode_models regression tests (bug #3169) ---
1232
1233    #[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(), // sentinel — must survive pruning
1245            ],
1246        );
1247
1248        let mut discovered = HashSet::new();
1249        discovered.insert("available-model".to_string());
1250        // "unavailable-model" intentionally absent
1251
1252        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        // discovered is empty — irrelevant for non-opencode agents
1309        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        // An empty pool derives its single entry from router_agent:router_model
1352        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        // "gone-model" is not in discovered
1382        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}